Compare commits
23
Commits
main
...
802ddd1102
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802ddd1102 | ||
|
|
c3d17b3daf | ||
|
|
9ad0c46088 | ||
|
|
36ce791215 | ||
|
|
eb880e0d14 | ||
|
|
ec3ffb9db3 | ||
|
|
3607eddc9f | ||
|
|
27687868ef | ||
|
|
2ff23d1fb0 | ||
|
|
409345cba3 | ||
|
|
e01a105645 | ||
|
|
20632be86a | ||
|
|
0f2866a853 | ||
|
|
31b2f33d15 | ||
|
|
c841748971 | ||
|
|
995206ac23 | ||
|
|
c568e496b2 | ||
|
|
9c3186252b | ||
|
|
cf187e42fa | ||
|
|
84ec3678c0 | ||
|
|
7778693807 | ||
|
|
54eb8dcb86 | ||
|
|
fda3e11f1e |
+8
-1
@@ -8,5 +8,12 @@ coverage/
|
||||
apps/web/dist/
|
||||
.hermes/
|
||||
.local-run/
|
||||
artifacts/
|
||||
apps/ios/.build/
|
||||
build/
|
||||
data/
|
||||
|
||||
# 误在工作副本里跑构建时留下的缓存目录
|
||||
apps/ios/apps/
|
||||
|
||||
# 图标由 Design/generate_app_icon.swift 重绘,产物不入库(正式资源在 Assets.xcassets)
|
||||
apps/ios/Design/AppIcon.png
|
||||
|
||||
@@ -2,6 +2,30 @@
|
||||
|
||||
一个用于聚合管理多个 [SimAdmin](https://github.com/3899/SimAdmin) 实例的轻量本地运维面板。
|
||||
|
||||
## iOS Native 应用
|
||||
|
||||
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**,构建号 10,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。
|
||||
|
||||
底部为节点、消息、设置三个入口:
|
||||
|
||||
- **节点**:运行概览、标签多选筛选、标签批量归类、搜索、批量选择、实例增删改,卡片直连实例详情。
|
||||
- **实例详情**:设备、SIM、蜂窝网络、系统资源、CPU、连接状态六个模块,敏感值默认脱敏,显示后离开页面即隐藏。
|
||||
- **消息**:跨实例短信读取、按实例与方向筛选、搜索、发送、删除。
|
||||
- **设置**:主题外观(跟随系统 / 浅色 / 深色)、应用信息、本地数据与日志中心、隐私与数据、数据清理。
|
||||
|
||||
实例信息与密码保存在应用沙盒本地文件,会话 Cookie 只存在于进程内存,不使用 Keychain,不上传任何数据。应用只访问用户添加的节点地址,上游路径为固定白名单。
|
||||
|
||||
按产品决策移除的能力:全局访问密码、定时与后台调度、iOS 远程推送、自动备份与自动清理、控制台自更新,以及依赖常驻进程的应用内通知中心(日志中心因此收进设置,不再单独占一个底部入口)。
|
||||
|
||||
构建与自检:
|
||||
|
||||
```bash
|
||||
./scripts/build-ios-ipa.sh # 产出 build/ipa/MultiSimAdmin.ipa 与 .sha256
|
||||
cd apps/ios && swift test # 48 个用例
|
||||
```
|
||||
|
||||
文档:现行说明 [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)。
|
||||
|
||||
## 功能
|
||||
|
||||
- 只需要在 `config.json` 配置多个 SimAdmin 地址。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -0,0 +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.06" y1="0" x2="0.94" y2="1">
|
||||
<stop offset="0" stop-color="#202020"/>
|
||||
<stop offset="0.55" stop-color="#131313"/>
|
||||
<stop offset="1" stop-color="#0A0A0A"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FCE5AA"/>
|
||||
<stop offset="0.46" stop-color="#F0C76F"/>
|
||||
<stop offset="1" stop-color="#D39B3D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#101010"/>
|
||||
<stop offset="1" stop-color="#202020"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#field)"/>
|
||||
|
||||
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
|
||||
<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="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>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,246 @@
|
||||
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
|
||||
// Usage: swift Design/generate_app_icon.swift <output.png>
|
||||
//
|
||||
// The mark is the web favicon grown up: a flat gold SIM plate with three
|
||||
// ascending slots cut out of it, on a neutral 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.
|
||||
//
|
||||
// The field is warm-neutral, not mathematically neutral. A perfectly grey R=G=B
|
||||
// plate sitting next to saturated gold loses the comparison and reads cool on a
|
||||
// lit home screen — that is how "there is no blue in it" still looked "blue" to
|
||||
// the eye. Red runs 2..4 levels over blue instead, which is invisible as a cast
|
||||
// but impossible to misread. The gold stays warm and deep so it reads metal
|
||||
// rather than mustard.
|
||||
//
|
||||
// Every coordinate is in visual space (origin top-left, y grows down).
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
let side: CGFloat = 1024
|
||||
let output = CommandLine.arguments.count > 1
|
||||
? CommandLine.arguments[1]
|
||||
: "Design/AppIcon.png"
|
||||
|
||||
func envDouble(_ name: String, _ fallback: Double) -> CGFloat {
|
||||
guard let raw = ProcessInfo.processInfo.environment[name], let value = Double(raw) else {
|
||||
return CGFloat(fallback)
|
||||
}
|
||||
return CGFloat(value)
|
||||
}
|
||||
|
||||
func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: Int = 255) -> CGColor {
|
||||
CGColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: CGFloat(a) / 255)
|
||||
}
|
||||
|
||||
guard let space = CGColorSpace(name: CGColorSpace.sRGB),
|
||||
let ctx = CGContext(
|
||||
data: nil, width: Int(side), height: Int(side), bitsPerComponent: 8, bytesPerRow: 0,
|
||||
space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||
else {
|
||||
FileHandle.standardError.write("cannot create bitmap context\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
ctx.translateBy(x: 0, y: side)
|
||||
ctx.scaleBy(x: 1, y: -1)
|
||||
|
||||
typealias Stop = (location: CGFloat, color: (Int, Int, Int, Int))
|
||||
|
||||
func makeGradient(_ stops: [Stop]) -> CGGradient? {
|
||||
var components: [CGFloat] = []
|
||||
for stop in stops {
|
||||
components.append(CGFloat(stop.color.0) / 255)
|
||||
components.append(CGFloat(stop.color.1) / 255)
|
||||
components.append(CGFloat(stop.color.2) / 255)
|
||||
components.append(CGFloat(stop.color.3) / 255)
|
||||
}
|
||||
let locations = stops.map(\.location)
|
||||
return CGGradient(
|
||||
colorSpace: space,
|
||||
colorComponents: components,
|
||||
locations: locations,
|
||||
count: stops.count
|
||||
)
|
||||
}
|
||||
|
||||
func fillLinear(_ path: CGPath, _ stops: [Stop], _ start: CGPoint, _ end: CGPoint) {
|
||||
guard let gradient = makeGradient(stops) else { return }
|
||||
ctx.saveGState()
|
||||
ctx.addPath(path)
|
||||
ctx.clip()
|
||||
ctx.drawLinearGradient(
|
||||
gradient, start: start, end: end,
|
||||
options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]
|
||||
)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
func fillRadial(_ center: CGPoint, _ radius: CGFloat, _ stops: [Stop]) {
|
||||
guard let gradient = makeGradient(stops) else { return }
|
||||
ctx.saveGState()
|
||||
ctx.drawRadialGradient(
|
||||
gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius,
|
||||
options: [.drawsAfterEndLocation]
|
||||
)
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
func rectPath(_ rect: CGRect) -> CGPath { CGPath(rect: rect, transform: nil) }
|
||||
|
||||
func roundedPath(_ rect: CGRect, _ radius: CGFloat) -> CGPath {
|
||||
CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)
|
||||
}
|
||||
|
||||
/// 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),
|
||||
]
|
||||
let path = CGMutablePath()
|
||||
path.move(to: vertices[0].point)
|
||||
for index in vertices.indices {
|
||||
path.addArc(
|
||||
tangent1End: vertices[index].point,
|
||||
tangent2End: vertices[(index + 1) % vertices.count].point,
|
||||
radius: vertices[index].radius
|
||||
)
|
||||
}
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
|
||||
/// 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()
|
||||
ctx.addPath(path)
|
||||
ctx.clip()
|
||||
ctx.addPath(path)
|
||||
ctx.setLineWidth(width)
|
||||
ctx.replacePathWithStrokedPath()
|
||||
ctx.clip()
|
||||
fillLinear(rectPath(rect.insetBy(dx: -8, dy: -8)), stops,
|
||||
CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY))
|
||||
ctx.restoreGState()
|
||||
}
|
||||
|
||||
// MARK: - Field
|
||||
|
||||
fillLinear(
|
||||
rectPath(CGRect(x: 0, y: 0, width: side, height: side)),
|
||||
[
|
||||
// 石墨底走暖一档:R 比 B 高 4 级,肉眼看不出偏色,但再也不可能被读成冷调。
|
||||
(0.00, (34, 32, 30, 255)),
|
||||
(0.55, (20, 19, 18, 255)),
|
||||
(1.00, (11, 10, 10, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: side * 0.06, y: 0),
|
||||
CGPoint(x: side * 0.94, y: side)
|
||||
)
|
||||
fillRadial(
|
||||
CGPoint(x: side * 0.22, y: -side * 0.10), CGFloat(side * 0.86),
|
||||
[(0.0, (232, 226, 214, 24)), (0.60, (232, 226, 214, 6)), (1.0, (232, 226, 214, 0))] as [Stop]
|
||||
)
|
||||
|
||||
// MARK: - Plate
|
||||
|
||||
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 = simPath(plateRect, corner: plateCorner, chamfer: plateChamfer)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -22), blur: 46, color: rgb(2, 1, 0, 150))
|
||||
ctx.addPath(plate)
|
||||
ctx.setFillColor(rgb(0, 0, 0))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
fillLinear(
|
||||
plate,
|
||||
[
|
||||
// 金色往香槟靠,收掉底部的芥末/棕调:小尺寸下这段最容易被读成"脏黄"。
|
||||
(0.00, (252, 229, 170, 255)),
|
||||
(0.46, (240, 199, 111, 255)),
|
||||
(1.00, (211, 155, 61, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: plateRect.minX, y: plateRect.minY),
|
||||
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
|
||||
)
|
||||
directionalRim(
|
||||
plate, plateRect, plateSide * 0.007,
|
||||
[
|
||||
(0.00, (255, 246, 224, 140)),
|
||||
(0.36, (255, 240, 206, 32)),
|
||||
(0.68, (255, 236, 196, 0)),
|
||||
] as [Stop]
|
||||
)
|
||||
|
||||
// MARK: - Slots
|
||||
|
||||
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]
|
||||
// 说明:以上默认值等价于 plate 相对比例(宽 .155 / 间距 .091 / 底边内缩 .265),
|
||||
// 改 plate 尺寸时按同一比例缩放才不会让槽和卡片脱节。
|
||||
let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap
|
||||
let slotsStartX = (side - slotsWidth) / 2
|
||||
|
||||
for (index, height) in slotHeights.enumerated() {
|
||||
let rect = CGRect(
|
||||
x: slotsStartX + CGFloat(index) * (slotWidth + slotGap),
|
||||
y: slotBottom - height,
|
||||
width: slotWidth,
|
||||
height: height
|
||||
)
|
||||
let radius = slotWidth * 0.30
|
||||
let path = roundedPath(rect, radius)
|
||||
|
||||
fillLinear(
|
||||
path,
|
||||
[
|
||||
// 插槽与底同色温,避免金面板上的洞看起来发青。
|
||||
(0.00, (17, 16, 14, 255)),
|
||||
(1.00, (34, 32, 30, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: rect.minX, y: rect.minY),
|
||||
CGPoint(x: rect.minX, y: rect.maxY)
|
||||
)
|
||||
|
||||
directionalRim(
|
||||
path, rect, radius * 0.30,
|
||||
[
|
||||
(0.00, (120, 82, 24, 96)),
|
||||
(0.52, (120, 82, 24, 16)),
|
||||
(1.00, (255, 240, 202, 64)),
|
||||
] as [Stop]
|
||||
)
|
||||
}
|
||||
|
||||
guard let image = ctx.makeImage() else {
|
||||
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let rep = NSBitmapImageRep(cgImage: image)
|
||||
guard let data = rep.representation(using: .png, properties: [:]) else {
|
||||
FileHandle.standardError.write("cannot encode png\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
try! data.write(to: URL(fileURLWithPath: output))
|
||||
print(output)
|
||||
@@ -0,0 +1,24 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "MultiSimAdmin",
|
||||
platforms: [.iOS(.v16), .macOS(.v13)],
|
||||
products: [
|
||||
.executable(name: "MultiSimAdmin", targets: ["MultiSimAdmin"])
|
||||
],
|
||||
dependencies: [],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "MultiSimAdmin",
|
||||
exclude: ["Resources/Info.plist"],
|
||||
resources: [
|
||||
.copy("Resources/Assets.xcassets")
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "MultiSimAdminTests",
|
||||
dependencies: ["MultiSimAdmin"]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
# MultiSimAdmin iOS
|
||||
|
||||
iPhone 原生应用的 Swift Package。产品与技术说明见 [docs/ios-native.md](../../docs/ios-native.md),
|
||||
本文件只写这个目录里怎么动手。
|
||||
|
||||
## 目录
|
||||
|
||||
```text
|
||||
Sources/MultiSimAdmin/
|
||||
AppMetadata.swift 版本号与 Bundle ID 的唯一来源
|
||||
MultiSimAdminApp.swift 入口、三个底部 Tab、调试直达参数
|
||||
Models/ 实例、标签、查询等值类型
|
||||
Features/{Fleet,Device,Messages,Logs,Settings}/ 页面与 view model
|
||||
Upstream/ 路由白名单、HTTP 客户端、按实例隔离的会话 Cookie
|
||||
Services/ 连接探测、凭据存储、依赖装配
|
||||
Persistence/ 沙盒 JSON 文件仓储与日志存储
|
||||
Theme/ 颜色令牌、按钮与卡片样式、骨架、主题模式
|
||||
Support/ 错误文案、时间显示、调试启动
|
||||
Resources/ Info.plist、Assets.xcassets
|
||||
Tests/MultiSimAdminTests/ 48 个用例
|
||||
Design/ 图标生成脚本与参考稿
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
swift build
|
||||
swift test
|
||||
xcodebuild -scheme MultiSimAdmin \
|
||||
-destination 'platform=iOS Simulator,id=<UDID>' \
|
||||
-configuration Debug build
|
||||
swift Design/generate_app_icon.swift \
|
||||
Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
|
||||
```
|
||||
|
||||
未签名 IPA 从仓库根目录执行 `./scripts/build-ios-ipa.sh` 生成。
|
||||
|
||||
## 发版要改的地方
|
||||
|
||||
版本号必须四处一致,否则测试会失败:
|
||||
|
||||
1. `Sources/MultiSimAdmin/Resources/Info.plist` 的 `CFBundleShortVersionString` 与 `CFBundleVersion`
|
||||
2. `Sources/MultiSimAdmin/AppMetadata.swift`
|
||||
3. `Tests/MultiSimAdminTests/AppMetadataTests.swift`
|
||||
4. `Tests/MultiSimAdminTests/SettingsViewModelTests.swift`
|
||||
|
||||
改完补一份 `docs/ios-native-<version>.md` 发布说明,并同步更新 `docs/ios-native.md`、仓库根
|
||||
README 的版本引用。
|
||||
|
||||
## 界面约定
|
||||
|
||||
界面不含任何蓝色:主操作、选中态、进度填充一律用石墨墨色(`AITheme.primary` / `AITheme.ink`),
|
||||
颜色只留给状态语义——危险红、告警琥珀、成功绿,且只在真的表示状态时出现,健康读数保持墨色。
|
||||
选中态用实心主色加 `onPrimary` 文字,不用低透明度色雾,浅色深色两套主题下都必须一眼可读;
|
||||
同屏并排控件等高;选中态不得改变控件尺寸;标签统一 28pt 胶囊;卡片内部不再嵌套卡片,
|
||||
并列读数改用发丝线分隔的栅格;不得出现因配色而在深色背景上消失的按钮或文字。
|
||||
@@ -0,0 +1,6 @@
|
||||
public enum AppMetadata {
|
||||
public static let name = "MultiSimAdmin"
|
||||
public static let bundleIdentifier = "fun.chick.multi-simadmin"
|
||||
public static let marketingVersion = "0.2.2"
|
||||
public static let buildVersion = "10"
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceSection: Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let title: String
|
||||
public let values: [String: String]
|
||||
}
|
||||
|
||||
public struct DeviceSnapshot: Equatable, Sendable {
|
||||
public let sections: [DeviceSection]
|
||||
}
|
||||
|
||||
public struct DeviceLinkState: Equatable, Sendable {
|
||||
public init(
|
||||
hardwareOnline: Bool? = nil,
|
||||
controlOnline: Bool? = nil,
|
||||
simPresent: Bool? = nil,
|
||||
cellularOnline: Bool? = nil
|
||||
) {
|
||||
self.hardwareOnline = hardwareOnline
|
||||
self.controlOnline = controlOnline
|
||||
self.simPresent = simPresent
|
||||
self.cellularOnline = cellularOnline
|
||||
}
|
||||
|
||||
public var hardwareOnline: Bool?
|
||||
public var controlOnline: Bool?
|
||||
public var simPresent: Bool?
|
||||
public var cellularOnline: Bool?
|
||||
}
|
||||
|
||||
extension DeviceService {
|
||||
static var placeholderSections: [DeviceSection] {
|
||||
[
|
||||
DeviceSection(id: "device", title: "设备", values: [:]),
|
||||
DeviceSection(id: "sim", title: "SIM", values: [:]),
|
||||
DeviceSection(id: "network", title: "蜂窝网络", values: [:]),
|
||||
DeviceSection(id: "stats", title: "系统资源", values: [:]),
|
||||
DeviceSection(id: "cpu", title: "CPU", values: [:]),
|
||||
DeviceSection(id: "connectivity", title: "连接状态", values: [:])
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
public struct DeviceSummary: Equatable, Sendable {
|
||||
public init(
|
||||
deviceName: String? = nil,
|
||||
phoneNumber: String? = nil,
|
||||
operatorName: String? = nil,
|
||||
networkTechnology: String? = nil,
|
||||
signalPercent: Int? = nil,
|
||||
cpuPercent: Double? = nil,
|
||||
memoryPercent: Double? = nil,
|
||||
cpuCount: Int? = nil,
|
||||
temperatureCelsius: Double? = nil,
|
||||
batteryPercent: Double? = nil,
|
||||
ipAddress: String? = nil,
|
||||
latencyMilliseconds: Int? = nil,
|
||||
connectivity: String? = nil,
|
||||
firmwareVersion: String? = nil,
|
||||
totalStorageBytes: Int64? = nil,
|
||||
availableStorageBytes: Int64? = nil,
|
||||
downloadRateBytesPerSecond: Double? = nil,
|
||||
uploadRateBytesPerSecond: Double? = nil,
|
||||
messagesToday: Int? = nil,
|
||||
uptimeSeconds: Double? = nil,
|
||||
linkState: DeviceLinkState = DeviceLinkState()
|
||||
) {
|
||||
self.deviceName = deviceName
|
||||
self.phoneNumber = phoneNumber
|
||||
self.operatorName = operatorName
|
||||
self.networkTechnology = networkTechnology
|
||||
self.signalPercent = signalPercent
|
||||
self.cpuPercent = cpuPercent
|
||||
self.memoryPercent = memoryPercent
|
||||
self.cpuCount = cpuCount
|
||||
self.temperatureCelsius = temperatureCelsius
|
||||
self.batteryPercent = batteryPercent
|
||||
self.ipAddress = ipAddress
|
||||
self.latencyMilliseconds = latencyMilliseconds
|
||||
self.connectivity = connectivity
|
||||
self.firmwareVersion = firmwareVersion
|
||||
self.totalStorageBytes = totalStorageBytes
|
||||
self.availableStorageBytes = availableStorageBytes
|
||||
self.downloadRateBytesPerSecond = downloadRateBytesPerSecond
|
||||
self.uploadRateBytesPerSecond = uploadRateBytesPerSecond
|
||||
self.messagesToday = messagesToday
|
||||
self.uptimeSeconds = uptimeSeconds
|
||||
self.linkState = linkState
|
||||
}
|
||||
|
||||
public var deviceName: String?
|
||||
public var phoneNumber: String?
|
||||
public var operatorName: String?
|
||||
public var networkTechnology: String?
|
||||
public var signalPercent: Int?
|
||||
public var cpuPercent: Double?
|
||||
public var memoryPercent: Double?
|
||||
public var cpuCount: Int?
|
||||
public var temperatureCelsius: Double?
|
||||
public var batteryPercent: Double?
|
||||
public var ipAddress: String?
|
||||
public var latencyMilliseconds: Int?
|
||||
public var connectivity: String?
|
||||
public var firmwareVersion: String?
|
||||
public var totalStorageBytes: Int64?
|
||||
public var availableStorageBytes: Int64?
|
||||
public var downloadRateBytesPerSecond: Double?
|
||||
public var uploadRateBytesPerSecond: Double?
|
||||
public var messagesToday: Int?
|
||||
public var uptimeSeconds: Double?
|
||||
public var linkState: DeviceLinkState
|
||||
}
|
||||
|
||||
public enum DeviceServiceError: Error, Equatable {
|
||||
case unauthorized
|
||||
case invalidResponse
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import Foundation
|
||||
|
||||
public struct DeviceService: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
public func snapshot(_ instance: Instance) async throws -> DeviceSnapshot {
|
||||
let routes: [(id: String, title: String, route: UpstreamRoute)] = [
|
||||
("device", "设备", .device),
|
||||
("sim", "SIM", .sim),
|
||||
("network", "蜂窝网络", .network),
|
||||
("stats", "系统资源", .stats),
|
||||
("cpu", "CPU", .statsCpu),
|
||||
("connectivity", "连接状态", .connectivity)
|
||||
]
|
||||
|
||||
var sections: [DeviceSection] = []
|
||||
var successful = 0
|
||||
var unauthorized = false
|
||||
for item in routes {
|
||||
do {
|
||||
let response = try await request(item.route, instance)
|
||||
let values = try Self.flatten(Self.dataObject(response.data))
|
||||
sections.append(DeviceSection(id: item.id, title: item.title, values: values))
|
||||
successful += 1
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
unauthorized = true
|
||||
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
|
||||
} catch {
|
||||
sections.append(DeviceSection(id: item.id, title: item.title, values: [:]))
|
||||
}
|
||||
}
|
||||
if successful == 0 {
|
||||
throw unauthorized ? DeviceServiceError.unauthorized : DeviceServiceError.invalidResponse
|
||||
}
|
||||
return DeviceSnapshot(sections: sections)
|
||||
}
|
||||
|
||||
public func summary(_ instance: Instance) async throws -> DeviceSummary {
|
||||
let routes: [(id: String, route: UpstreamRoute)] = [
|
||||
("device", .device),
|
||||
("sim", .sim),
|
||||
("network", .network),
|
||||
("stats", .stats),
|
||||
("cpu", .statsCpu),
|
||||
("connectivity", .connectivity)
|
||||
]
|
||||
|
||||
var sections: [(String, [String: String])] = []
|
||||
var successful = 0
|
||||
for item in routes {
|
||||
do {
|
||||
let response = try await request(item.route, instance)
|
||||
let values = try Self.flatten(Self.dataObject(response.data))
|
||||
sections.append((item.id, values))
|
||||
successful += 1
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
guard successful > 0 else {
|
||||
throw DeviceServiceError.invalidResponse
|
||||
}
|
||||
return Self.summary(from: sections)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw DeviceServiceError.unauthorized
|
||||
}
|
||||
let sessionService = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessionService.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessionService.cookie(for: instance)
|
||||
else {
|
||||
throw DeviceServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
static func dataObject(_ data: Data) throws -> [String: Any] {
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw DeviceServiceError.invalidResponse
|
||||
}
|
||||
if let payload = root["data"] as? [String: Any] { return payload }
|
||||
if let response = root["response"] as? [String: Any],
|
||||
let payload = response["data"] as? [String: Any] {
|
||||
return payload
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
static func summary(from sections: [(String, [String: String])]) -> DeviceSummary {
|
||||
func key(_ sectionID: String, _ names: [String]) -> String? {
|
||||
guard let section = sections.first(where: { $0.0 == sectionID }) else { return nil }
|
||||
return names.lazy.compactMap { section.1[$0] }.first { !$0.isEmpty }
|
||||
}
|
||||
|
||||
func number(_ text: String?) -> Double? {
|
||||
guard let text = text?.replacingOccurrences(of: "%", with: "") else { return nil }
|
||||
return Double(text)
|
||||
}
|
||||
|
||||
func bool(_ text: String?) -> Bool? {
|
||||
switch text?.lowercased() {
|
||||
case "是", "true", "yes", "1": return true
|
||||
case "否", "false", "no", "0": return false
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
let linkState = DeviceLinkState(
|
||||
hardwareOnline: bool(key("device", ["powered"])),
|
||||
controlOnline: bool(key("device", ["online"])),
|
||||
simPresent: sections.contains { $0.0 == "sim" },
|
||||
cellularOnline: key("network", ["registration_status"])?
|
||||
.lowercased()
|
||||
.hasPrefix("registered")
|
||||
)
|
||||
|
||||
return DeviceSummary(
|
||||
deviceName: key("device", ["model", "name", "device"]),
|
||||
phoneNumber: key("sim", ["phone_numbers", "phone_number"]),
|
||||
operatorName: key("network", ["operator_name", "operator", "carrier"]),
|
||||
networkTechnology: key(
|
||||
"network",
|
||||
["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
),
|
||||
signalPercent: number(key("network", ["signal_percent", "signal_strength"]))
|
||||
.flatMap { Int($0.rounded()) },
|
||||
cpuPercent: number(key("cpu", ["cpu_percent", "cpu_load", "usage"])),
|
||||
memoryPercent: number(key("stats", ["memory_percent", "usage"])),
|
||||
cpuCount: number(key("cpu", ["core_count", "cores"])).flatMap { Int($0.rounded()) },
|
||||
temperatureCelsius: number(key("stats", ["temperature", "max_temperature_c"])),
|
||||
batteryPercent: number(key("stats", ["battery_percent", "battery_level"])),
|
||||
ipAddress: key("network", ["ipv4", "ip_address", "ipv6"]),
|
||||
latencyMilliseconds: number(key("connectivity", ["latency_ms", "latency"])).flatMap {
|
||||
Int($0.rounded())
|
||||
},
|
||||
connectivity: key("connectivity", ["state", "connected", "latency_ms"]),
|
||||
firmwareVersion: key("device", ["revision", "firmware_version"]),
|
||||
totalStorageBytes: Self.byteCount(
|
||||
key("stats", ["disk_total_bytes", "storage_total_bytes", "total_bytes"])
|
||||
),
|
||||
availableStorageBytes: Self.byteCount(
|
||||
key("stats", ["disk_available_bytes", "storage_available_bytes", "available_bytes"])
|
||||
),
|
||||
downloadRateBytesPerSecond: Self.byteRate(
|
||||
key("connectivity", ["download", "download_bytes_per_second", "rx_rate"])
|
||||
),
|
||||
uploadRateBytesPerSecond: Self.byteRate(
|
||||
key("connectivity", ["upload", "upload_bytes_per_second", "tx_rate"])
|
||||
),
|
||||
messagesToday: number(
|
||||
key("connectivity", ["messages_today", "messages", "sms_today"])
|
||||
).flatMap { Int($0.rounded()) },
|
||||
uptimeSeconds: number(
|
||||
key("stats", ["uptime_seconds", "uptime", "system_uptime_seconds"])
|
||||
) ?? number(key("stats", ["uptime_uptime_seconds"]))
|
||||
?? number(key("stats", ["system_uptime_seconds"])),
|
||||
linkState: linkState
|
||||
)
|
||||
}
|
||||
|
||||
private static func byteCount(_ text: String?) -> Int64? {
|
||||
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return Int64(value.rounded())
|
||||
}
|
||||
|
||||
private static func byteRate(_ text: String?) -> Double? {
|
||||
guard let text, let value = Double(text.replacingOccurrences(of: "%", with: "")), value >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
static func flatten(_ source: [String: Any], maximum: Int = 40) -> [String: String] {
|
||||
var output: [String: String] = [:]
|
||||
flatten(source, prefix: "", into: &output, remaining: maximum)
|
||||
return output
|
||||
}
|
||||
|
||||
private static func flatten(
|
||||
_ source: [String: Any],
|
||||
prefix: String,
|
||||
into output: inout [String: String],
|
||||
remaining: Int
|
||||
) {
|
||||
for (key, value) in source.sorted(by: { $0.key < $1.key }) {
|
||||
guard output.count < remaining else { return }
|
||||
let fullKey = prefix.isEmpty ? key : "\(prefix)_\(key)"
|
||||
switch value {
|
||||
case let text as String:
|
||||
output[fullKey] = text
|
||||
case let flag as Bool:
|
||||
output[fullKey] = flag ? "是" : "否"
|
||||
case let number as Int:
|
||||
output[fullKey] = String(number)
|
||||
case let number as Double:
|
||||
output[fullKey] = String(format: "%.1f", number)
|
||||
case let array as [Any]:
|
||||
let text = array.compactMap { entry -> String? in
|
||||
if let record = entry as? [String: Any] {
|
||||
let preferred = ["name", "zone", "mount_point", "interface", "model_name"]
|
||||
.compactMap { record[$0] as? String }
|
||||
.first
|
||||
if let preferred { return preferred }
|
||||
}
|
||||
return entry as? String
|
||||
}.joined(separator: "、")
|
||||
output[fullKey] = text
|
||||
case let nested as [String: Any]:
|
||||
flatten(nested, prefix: fullKey, into: &output, remaining: remaining - output.count)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class DeviceViewModel: ObservableObject {
|
||||
@Published var snapshot: DeviceSnapshot?
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published private(set) var connectionStatus: ConnectionStatus = .unknown
|
||||
|
||||
let instance: Instance
|
||||
private let service: DeviceService
|
||||
private let probe: ConnectionProbe?
|
||||
|
||||
init(instance: Instance, service: DeviceService, probe: ConnectionProbe? = nil) {
|
||||
self.instance = instance
|
||||
self.service = service
|
||||
self.probe = probe
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
snapshot = try await service.snapshot(instance)
|
||||
} catch DeviceServiceError.unauthorized {
|
||||
errorMessage = "需要实例密码登录后才能读取设备数据。"
|
||||
} catch {
|
||||
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
|
||||
}
|
||||
guard let probe else {
|
||||
connectionStatus = .offline
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
let result = try? await probe.test(instance)
|
||||
connectionStatus = result?.status ?? .offline
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,990 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceDetailView: View {
|
||||
@StateObject private var model: DeviceViewModel
|
||||
@State private var revealsSensitive = false
|
||||
@State private var showsSensitiveInfo = false
|
||||
|
||||
init(
|
||||
instance: Instance,
|
||||
model: DeviceViewModel? = nil
|
||||
) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeDeviceModel(instance: instance))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
if isUnavailable {
|
||||
unavailableCard
|
||||
} else {
|
||||
headerCard
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let snapshot = model.snapshot {
|
||||
ForEach(snapshot.sections) { section in
|
||||
sectionCard(section)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle(model.instance.name)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $showsSensitiveInfo) {
|
||||
SensitiveValueSheet(
|
||||
values: sensitiveValues,
|
||||
isRevealed: $revealsSensitive
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 读取失败且从未拿到数据时,整屏骨架没有意义,改成一屏可读的失败卡片。
|
||||
private var isUnavailable: Bool {
|
||||
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
|
||||
}
|
||||
|
||||
private var unavailableCard: some View {
|
||||
VStack(spacing: 12) {
|
||||
AICard(padding: 16, emphasis: true) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(AITheme.danger)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(AITheme.danger.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
Text("节点无法连接")
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Spacer(minLength: 8)
|
||||
statusChip(title: "不可达", tint: AITheme.danger)
|
||||
}
|
||||
|
||||
Text(model.errorMessage ?? "无法加载设备数据。")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button {
|
||||
Task { await model.refresh() }
|
||||
} label: {
|
||||
Label("重新检测", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("本机保存的配置", systemImage: "slider.horizontal.3")
|
||||
AIKeyValueRow("地址", value: model.instance.origin.absoluteString, monospaced: true)
|
||||
AIKeyValueRow("标签", value: model.instance.tags.isEmpty ? "无" : model.instance.tags.joined(separator: "、"))
|
||||
AIKeyValueRow("访问凭据", value: model.instance.credentialConfigured ? "已保存" : "未设置")
|
||||
}
|
||||
}
|
||||
|
||||
// 配置是否丢失不是用户需要被告知的事,卡片本身已经说明了一切。
|
||||
}
|
||||
}
|
||||
|
||||
private var headerCard: some View {
|
||||
AICard(padding: 16, emphasis: true) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "globe")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.footnote.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 8)
|
||||
statusChip()
|
||||
}
|
||||
|
||||
DetailMetricGrid(metrics: gaugeMetrics)
|
||||
|
||||
DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading)
|
||||
|
||||
moduleAvailability
|
||||
|
||||
// 与节点卡片同一枚标签形状(同一胶囊、同一图标),两处只有一处能点。
|
||||
if model.instance.tags.isEmpty == false {
|
||||
FlowLayout(spacing: 7) {
|
||||
ForEach(model.instance.tags, id: \.self) { tag in
|
||||
AITag(tag, systemImage: "tag")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusChip(title: String? = nil, tint overrideTint: Color? = nil) -> some View {
|
||||
let loading = model.isLoading && model.snapshot == nil
|
||||
let tint = overrideTint ?? (loading ? AITheme.muted : statusColor(model.connectionStatus))
|
||||
let label = title ?? (loading ? "检测中" : model.connectionStatus.label)
|
||||
|
||||
return HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(tint)
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityHidden(true)
|
||||
Text(label)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 30)
|
||||
.background(tint.opacity(0.12))
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.22))
|
||||
}
|
||||
.accessibilityLabel("连接状态 \(label)")
|
||||
}
|
||||
|
||||
/// 模块可用性压成一行读数:六个胶囊和下方分栏标题一一对应,属于纯重复。
|
||||
/// 只有真的读不到时才展开,直接点名是哪几个模块。
|
||||
private var moduleAvailability: some View {
|
||||
let unavailable = moduleSections.filter { $0.values.isEmpty }.map(\.title)
|
||||
return VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Text("数据模块")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Spacer()
|
||||
if let available = availableCount {
|
||||
Image(systemName: available == moduleSections.count
|
||||
? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(available == moduleSections.count
|
||||
? AITheme.success : AITheme.warning)
|
||||
.accessibilityHidden(true)
|
||||
Text("\(available)/\(moduleSections.count) 可用")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(available == moduleSections.count
|
||||
? AITheme.success : AITheme.warning)
|
||||
} else {
|
||||
Text("读取中")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
if unavailable.isEmpty == false {
|
||||
Text("未返回:" + unavailable.joined(separator: " · "))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct HeaderMetric: Identifiable {
|
||||
let title: String
|
||||
let value: String?
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
let progress: Double?
|
||||
|
||||
var id: String { title }
|
||||
// 与节点卡片同一规则:只有越过警告线才着色,正常值走墨色。
|
||||
var isAlert: Bool { tint == AITheme.danger || tint == AITheme.warning }
|
||||
}
|
||||
|
||||
private var allHeaderMetrics: [HeaderMetric] {
|
||||
let loading = model.isLoading
|
||||
let phoneValue = stringValue(
|
||||
section: "sim",
|
||||
keys: ["phone_numbers", "phone_number"]
|
||||
)
|
||||
let storageValue = storageSummary
|
||||
let uptimeValue = stringValue(
|
||||
section: "stats",
|
||||
keys: ["uptime_seconds", "uptime", "system_uptime_seconds"]
|
||||
).flatMap(durationValue)
|
||||
let signal = summaryValue(
|
||||
section: "network",
|
||||
keys: ["signal_percent", "signal_strength"],
|
||||
format: loading ? nil : { "\(Int($0.rounded()))%" },
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { signalTint($0) }
|
||||
)
|
||||
let latency = summaryValue(
|
||||
section: "connectivity",
|
||||
keys: ["latency_ms", "latency"],
|
||||
format: loading ? nil : { "\(Int($0.rounded())) ms" },
|
||||
// 进度条读的是"链路质量",和节点卡片同一把尺子(1000ms 归零),
|
||||
// 之前恒为 1,满格黑条压在 38ms 上会被读成"延迟占满了什么"。
|
||||
progress: loading ? nil : { max(0.04, min(1, 1 - $0 / 1000)) },
|
||||
tint: loading ? nil : { latencyTint($0) }
|
||||
)
|
||||
let cpu = summaryValue(
|
||||
section: "cpu",
|
||||
keys: ["cpu_percent", "cpu_load", "usage"],
|
||||
format: loading ? nil : { "\(Int($0.rounded()))%" },
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { resourceTint($0) }
|
||||
)
|
||||
let memory = summaryValue(
|
||||
section: "stats",
|
||||
keys: ["memory_percent", "usage"],
|
||||
format: loading ? nil : { "\(Int($0.rounded()))%" },
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { resourceTint($0) }
|
||||
)
|
||||
return [
|
||||
makeMetric(
|
||||
title: "号码",
|
||||
value: loading ? nil : phoneValue ?? "无数据",
|
||||
systemImage: "simcard",
|
||||
tint: loading ? nil : AITheme.ink
|
||||
),
|
||||
makeMetric(title: "信号", value: loading ? nil : signal?.value, systemImage: "cellularbars", tint: loading ? nil : signal?.tint, progress: loading ? nil : signal?.progress),
|
||||
makeMetric(title: "延迟", value: loading ? nil : latency?.value, systemImage: "speedometer", tint: loading ? nil : latency?.tint, progress: loading ? nil : latency?.progress),
|
||||
makeMetric(
|
||||
title: "CPU",
|
||||
value: loading ? nil : cpu?.value ?? "无数据",
|
||||
systemImage: "cpu",
|
||||
tint: loading ? nil : cpu?.tint ?? AITheme.muted,
|
||||
progress: loading ? nil : cpu?.progress
|
||||
),
|
||||
makeMetric(
|
||||
title: "内存",
|
||||
value: loading ? nil : memory?.value ?? "无数据",
|
||||
systemImage: "memorychip",
|
||||
tint: loading ? nil : memory?.tint ?? AITheme.muted,
|
||||
progress: loading ? nil : memory?.progress
|
||||
),
|
||||
makeMetric(title: "存储", value: loading ? nil : storageValue, systemImage: "internaldrive", tint: loading ? nil : storageTint, progress: loading ? nil : storageProgress),
|
||||
makeMetric(title: "运行时间", value: loading ? nil : uptimeValue, systemImage: "clock", tint: loading ? nil : AITheme.ink)
|
||||
]
|
||||
}
|
||||
|
||||
// 头部只保留四块带进度语义的量表,其余信息压缩成文本行。
|
||||
private static let gaugeOrder: [String] = ["信号", "延迟", "CPU", "内存"]
|
||||
|
||||
private var gaugeMetrics: [HeaderMetric] {
|
||||
let metrics = allHeaderMetrics
|
||||
return Self.gaugeOrder.compactMap { title in
|
||||
metrics.first { $0.title == title }
|
||||
}
|
||||
}
|
||||
|
||||
private var identitySummary: [(label: String, value: String)] {
|
||||
let metrics = allHeaderMetrics
|
||||
func derived(_ title: String) -> String? {
|
||||
metrics.first { $0.title == title }?.value
|
||||
}
|
||||
// 顶部只留"这是哪张卡、连在哪个网上"四件事;
|
||||
// 设备型号、固件、存储、运行时间属于硬件清单,下方分栏里已经逐条列出。
|
||||
let pairs: [(String, String?)] = [
|
||||
("号码", derived("号码")),
|
||||
("运营商", stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])),
|
||||
("网络", stringValue(
|
||||
section: "network",
|
||||
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
)),
|
||||
("IP", stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"]))
|
||||
]
|
||||
return pairs.compactMap { label, value in
|
||||
guard let value, value.isEmpty == false, value != "无数据" else { return nil }
|
||||
return (label, value)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeMetric(
|
||||
title: String,
|
||||
value: String?,
|
||||
systemImage: String,
|
||||
tint: Color?,
|
||||
progress: Double? = nil
|
||||
) -> HeaderMetric {
|
||||
HeaderMetric(title: title, value: value, systemImage: systemImage, tint: tint, progress: progress)
|
||||
}
|
||||
|
||||
private var storageSummary: String {
|
||||
guard let total = numericValue(section: "stats", keys: [
|
||||
"disk_total_bytes", "storage_total_bytes", "total_bytes"
|
||||
]), let available = numericValue(section: "stats", keys: [
|
||||
"disk_available_bytes", "storage_available_bytes", "available_bytes"
|
||||
]), total > 0, available >= 0, total >= available else {
|
||||
return "无数据"
|
||||
}
|
||||
return "\(Int((Double(total - available) / Double(total) * 100).rounded()))% 已用"
|
||||
}
|
||||
|
||||
private var storageTint: Color {
|
||||
if storageRatio == nil { return AITheme.ink }
|
||||
return resourceTint(storageRatio! * 100)
|
||||
}
|
||||
|
||||
private var storageProgress: Double {
|
||||
storageRatio ?? 0
|
||||
}
|
||||
|
||||
private var storageRatio: Double? {
|
||||
guard let total = numericValue(section: "stats", keys: [
|
||||
"disk_total_bytes", "storage_total_bytes", "total_bytes"
|
||||
]), let available = numericValue(section: "stats", keys: [
|
||||
"disk_available_bytes", "storage_available_bytes", "available_bytes"
|
||||
]), total > 0, available >= 0, total >= available else {
|
||||
return nil
|
||||
}
|
||||
return Double(total - available) / Double(total)
|
||||
}
|
||||
|
||||
private func numericValue(
|
||||
section: String,
|
||||
keys: [String]
|
||||
) -> Int64? {
|
||||
stringValue(section: section, keys: keys)
|
||||
.flatMap { Double($0.replacingOccurrences(of: "%", with: "")) }
|
||||
.flatMap { Int64($0.rounded()) }
|
||||
}
|
||||
|
||||
private func durationValue(_ text: String) -> String? {
|
||||
guard let seconds = Double(text.replacingOccurrences(of: "%", with: "")), seconds >= 0 else {
|
||||
return nil
|
||||
}
|
||||
return Duration.seconds(seconds).formatted(
|
||||
.units(allowed: [.days, .hours, .minutes], width: .abbreviated)
|
||||
)
|
||||
}
|
||||
|
||||
private func statusColor(_ value: ConnectionStatus) -> Color {
|
||||
switch value {
|
||||
case .online: return AITheme.success
|
||||
case .degraded, .authRequired: return AITheme.warning
|
||||
case .offline: return AITheme.danger
|
||||
case .unknown: return AITheme.muted
|
||||
}
|
||||
}
|
||||
|
||||
private struct FormattedMetric {
|
||||
let value: String
|
||||
let tint: Color
|
||||
let progress: Double
|
||||
}
|
||||
|
||||
private func stringValue(
|
||||
section: String,
|
||||
keys: [String]
|
||||
) -> String? {
|
||||
guard let values = model.snapshot?.sections.first(where: { $0.id == section })?.values else {
|
||||
return nil
|
||||
}
|
||||
return keys.lazy.compactMap { values[$0] }.first { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func summaryValue(
|
||||
section: String,
|
||||
keys: [String],
|
||||
format: ((Double) -> String)?,
|
||||
progress: ((Double) -> Double)?,
|
||||
tint: ((Double) -> Color)?
|
||||
) -> FormattedMetric? {
|
||||
guard let snapshot = model.snapshot,
|
||||
let values = snapshot.sections.first(where: { $0.id == section })?.values
|
||||
else { return nil }
|
||||
|
||||
let raw = keys.compactMap { values[$0] }.first { !$0.isEmpty }
|
||||
guard let raw, let number = Double(raw.replacingOccurrences(of: "%", with: "")), number >= 0 else {
|
||||
return nil
|
||||
}
|
||||
guard let format, let progress, let tint else { return nil }
|
||||
return FormattedMetric(
|
||||
value: format(number),
|
||||
tint: tint(number),
|
||||
progress: progress(number)
|
||||
)
|
||||
}
|
||||
|
||||
/// 阈值只回答"要不要提醒",健康值不着色,四格量表不会染成一片绿。
|
||||
private func signalTint(_ value: Double) -> Color {
|
||||
value >= 60 ? AITheme.ink : (value >= 30 ? AITheme.warning : AITheme.danger)
|
||||
}
|
||||
|
||||
private func latencyTint(_ value: Double) -> Color {
|
||||
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.ink)
|
||||
}
|
||||
|
||||
private struct DetailHeaderMetric: View {
|
||||
let metric: HeaderMetric
|
||||
|
||||
var title: String { metric.title }
|
||||
var value: String? { metric.value }
|
||||
var systemImage: String { metric.systemImage }
|
||||
var tint: Color { metric.tint ?? AITheme.muted }
|
||||
var progress: Double { metric.progress ?? 0 }
|
||||
var isProgressVisible: Bool {
|
||||
metric.title == "连接状态" || metric.progress != nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(metric.isAlert ? tint : AITheme.faint)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
if let value {
|
||||
Text(value)
|
||||
.font(.title3.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(metric.isAlert ? tint : AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.72)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Capsule(style: .continuous)
|
||||
.fill(AITheme.line.opacity(0.55))
|
||||
.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)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 56, alignment: .topLeading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
/// 两列发丝线栅格:量表是头部卡片内的读数,不再各自套一层底色块。
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
|
||||
private struct DetailIdentityStrip: View {
|
||||
let items: [(label: String, value: String)]
|
||||
let isLoading: Bool
|
||||
|
||||
var body: some View {
|
||||
if isLoading {
|
||||
// 首屏还没有数据时用骨架条,避免出现一整列"读取中"。
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
ForEach(0..<3, id: \.self) { _ in
|
||||
HStack(spacing: 8) {
|
||||
Capsule(style: .continuous)
|
||||
.fill(AITheme.line.opacity(0.7))
|
||||
.frame(width: 42, height: 10)
|
||||
Capsule(style: .continuous)
|
||||
.fill(AITheme.line.opacity(0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.frame(height: 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityLabel("设备信息读取中")
|
||||
} else if items.isEmpty == false {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(items, id: \.label) { item in
|
||||
// 值一律右对齐,和下方分区卡片的键值行同一节奏;
|
||||
// 之前左边缩进 52pt、值顶在中间,同屏两种排法读起来像两张不同的表。
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(item.label)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
Spacer(minLength: 12)
|
||||
Text(item.value)
|
||||
.font(.caption.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
.padding(.top, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isAvailable ? AITheme.success : AITheme.control)
|
||||
.frame(width: 6, height: 6)
|
||||
.overlay {
|
||||
Circle()
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
Text(title)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(isAvailable ? AITheme.ink : AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
// FlowLayout 按固有尺寸排布,这里不能再用 maxWidth: .infinity。
|
||||
private struct FlowLayout: Layout {
|
||||
let spacing: CGFloat
|
||||
var lineSpacing: CGFloat?
|
||||
|
||||
private var rowGap: CGFloat { lineSpacing ?? spacing }
|
||||
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
var totalHeight: CGFloat = 0
|
||||
var widestRow: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > 0, x + size.width > maxWidth {
|
||||
widestRow = max(widestRow, x - spacing)
|
||||
totalHeight += rowHeight + rowGap
|
||||
x = 0
|
||||
rowHeight = 0
|
||||
}
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
widestRow = max(widestRow, x - spacing)
|
||||
totalHeight += rowHeight
|
||||
|
||||
return CGSize(width: max(0, widestRow), height: max(0, totalHeight))
|
||||
}
|
||||
|
||||
func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > bounds.minX, x + size.width > bounds.maxX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + rowGap
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var availableCount: Int? {
|
||||
guard let snapshot = model.snapshot else { return nil }
|
||||
return snapshot.sections.filter { !$0.values.isEmpty }.count
|
||||
}
|
||||
|
||||
private var moduleSections: [DeviceSection] {
|
||||
model.snapshot?.sections ?? DeviceService.placeholderSections
|
||||
}
|
||||
|
||||
private var sensitiveValues: [(title: String, value: String)] {
|
||||
guard let snapshot = model.snapshot else { return [] }
|
||||
return snapshot.sections.flatMap { section in
|
||||
section.values
|
||||
.filter { sensitiveKeys.contains($0.key) }
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { (fieldLabel($0.key), $0.value) }
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionCard(_ section: DeviceSection) -> some View {
|
||||
if section.values.isEmpty {
|
||||
AICard(padding: 14) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "wifi.slash")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 11, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(section.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Text("模块未返回数据")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
|
||||
// 行与行只靠发丝线分隔,纵向节奏交给 row 自己的内边距,
|
||||
// 否则卡片会越拉越长,一屏读不到两组数据。
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.6))
|
||||
.padding(.vertical, 9)
|
||||
}
|
||||
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionIcon(_ id: String) -> String {
|
||||
switch id {
|
||||
case "device": return "iphone.gen3"
|
||||
case "sim": return "simcard"
|
||||
case "network": return "antenna.radiowaves.left.and.right"
|
||||
case "stats": return "chart.bar.fill"
|
||||
case "cpu": return "cpu"
|
||||
case "connectivity": return "dot.radiowaves.left.and.right"
|
||||
default: return "list.bullet.rectangle"
|
||||
}
|
||||
}
|
||||
|
||||
private let fieldLabels: [String: String] = [
|
||||
"model": "型号",
|
||||
"manufacturer": "制造商",
|
||||
"imei": "IMEI",
|
||||
"revision": "固件版本",
|
||||
"online": "在线",
|
||||
"powered": "基带电源",
|
||||
"present": "SIM 在位",
|
||||
"iccid": "ICCID",
|
||||
"imsi": "IMSI",
|
||||
"phone_numbers": "号码",
|
||||
"sms_center": "短信中心",
|
||||
"operator_name": "运营商",
|
||||
"registration_status": "注册状态",
|
||||
"technology_preference": "网络制式",
|
||||
"signal_strength": "信号强度",
|
||||
"brand": "品牌",
|
||||
"meid": "MEID",
|
||||
"phone_number": "号码",
|
||||
"firmware_version": "固件版本",
|
||||
"os_version": "系统版本",
|
||||
"baseband_version": "基带版本",
|
||||
"uptime": "运行时间",
|
||||
"uptime_seconds": "运行时间",
|
||||
"slots": "卡槽数",
|
||||
"active_slots": "活跃数",
|
||||
"active": "已激活",
|
||||
"enabled": "已启用",
|
||||
"operator": "运营商",
|
||||
"carrier": "运营商",
|
||||
"network_type": "接入制式",
|
||||
"technology": "技术",
|
||||
"radio_mode": "网络模式",
|
||||
"signal": "信号强度",
|
||||
"signal_dbm": "信号强度(dBm)",
|
||||
"signal_percent": "信号质量",
|
||||
"rsrp": "RSRP",
|
||||
"rsrq": "RSRQ",
|
||||
"sinr": "SINR",
|
||||
"mcc": "MCC",
|
||||
"mnc": "MNC",
|
||||
"registration": "注册状态",
|
||||
"registration_state": "注册状态",
|
||||
"roaming": "漫游",
|
||||
"airplane_mode": "飞行模式",
|
||||
"ipv4": "IPv4 地址",
|
||||
"ipv6": "IPv6 地址",
|
||||
"ip_address": "IP 地址",
|
||||
"gateway": "网关",
|
||||
"dns": "DNS",
|
||||
"download": "下行速率",
|
||||
"upload": "上行速率",
|
||||
"download_bytes": "下行流量",
|
||||
"upload_bytes": "上行流量",
|
||||
"messages_today": "今日消息数",
|
||||
"calls": "通话数",
|
||||
"usage": "使用率",
|
||||
"temperature": "温度",
|
||||
"max_temperature_c": "最高温度",
|
||||
"cpu_percent": "CPU 使用率",
|
||||
"cpu_load": "CPU 负载",
|
||||
"memory_percent": "内存使用率",
|
||||
"memory_total_mb": "内存总量",
|
||||
"memory_available_mb": "可用内存",
|
||||
"battery_level": "电量",
|
||||
"battery_percent": "电量",
|
||||
"latency": "延迟",
|
||||
"connected": "连接状态",
|
||||
"interface": "网络接口",
|
||||
"ssid": "无线网络",
|
||||
"apn": "APN",
|
||||
"core_count": "核心数",
|
||||
"model_name": "CPU 型号",
|
||||
"state": "连接状态",
|
||||
"latency_ms": "延迟(ms)",
|
||||
"name": "设备名称",
|
||||
"disk_total_bytes": "存储总量",
|
||||
"disk_available_bytes": "可用存储",
|
||||
"percent": "使用率"
|
||||
]
|
||||
|
||||
private let valueOrder: [String] = [
|
||||
"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",
|
||||
"radio_mode", "signal_strength", "signal_dbm", "signal_percent", "rsrp", "rsrq", "sinr",
|
||||
"state", "connected", "latency", "latency_ms", "uptime", "uptime_seconds", "cpu_percent",
|
||||
"cpu_load", "memory_percent", "memory_total_mb", "memory_available_mb", "usage",
|
||||
"temperature", "max_temperature_c", "battery_level", "battery_percent", "download",
|
||||
"upload", "download_bytes", "upload_bytes", "ipv4", "ipv6", "ip_address", "gateway",
|
||||
"dns", "interface", "ssid", "apn", "core_count", "model_name", "messages_today", "calls",
|
||||
"disk_total_bytes", "disk_available_bytes", "percent"
|
||||
]
|
||||
|
||||
private let durationKeys: Set<String> = ["uptime", "uptime_seconds"]
|
||||
private let percentKeys: Set<String> = ["usage", "cpu_percent", "memory_percent"]
|
||||
private let byteRateKeys: Set<String> = ["download", "upload"]
|
||||
private let byteCountKeys: Set<String> = ["download_bytes", "upload_bytes"]
|
||||
private let sensitiveKeys: Set<String> = ["imei", "iccid", "imsi", "phone_numbers", "sms_center"]
|
||||
|
||||
private func sortedValues(_ values: [String: String]) -> [(key: String, value: String)] {
|
||||
values
|
||||
.map { (key: $0.key, value: $0.value) }
|
||||
.sorted { lhs, rhs in
|
||||
let lhsRank = valueOrder.firstIndex(of: lhs.key) ?? Int.max
|
||||
let rhsRank = valueOrder.firstIndex(of: rhs.key) ?? Int.max
|
||||
if lhsRank != rhsRank { return lhsRank < rhsRank }
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
}
|
||||
|
||||
private func fieldLabel(_ key: String) -> String {
|
||||
if let exact = fieldLabels[key] { return exact }
|
||||
let parts = key.split(separator: "_").map(String.init)
|
||||
for count in [2, 1] where parts.count >= count {
|
||||
let suffix = parts.suffix(count).joined(separator: "_")
|
||||
if let label = fieldLabels[suffix] { return label }
|
||||
}
|
||||
return parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func display(_ key: String, _ value: String) -> String {
|
||||
if matches(key, durationKeys), let seconds = Double(value), seconds >= 0 {
|
||||
return Duration.seconds(seconds).formatted(.units(allowed: [.days, .hours, .minutes], width: .abbreviated))
|
||||
}
|
||||
if matches(key, percentKeys), let number = Double(value), number >= 0 {
|
||||
return "\(number.formatted(.number.precision(.fractionLength(0...1))))%"
|
||||
}
|
||||
if matches(key, byteRateKeys), let bytesPerSecond = Double(value), bytesPerSecond >= 0 {
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(bytesPerSecond), countStyle: .decimal) + "/s"
|
||||
}
|
||||
if matches(key, byteCountKeys), let bytes = Double(value), bytes >= 0 {
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .decimal)
|
||||
}
|
||||
if matches(key, ["memory_total_mb"]), let megabytes = Double(value) {
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
|
||||
}
|
||||
if matches(key, ["memory_available_mb"]), let megabytes = Double(value) {
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(megabytes * 1_048_576), countStyle: .memory)
|
||||
}
|
||||
if key.hasSuffix("temperature"), let temperature = Double(value) {
|
||||
return "\(temperature.formatted(.number.precision(.fractionLength(0...1))))°C"
|
||||
}
|
||||
if sensitiveKeys.contains(key), !revealsSensitive {
|
||||
return masked(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func masked(_ value: String) -> String {
|
||||
guard let first = value.first, value.count > 6 else {
|
||||
return String(repeating: "•", count: min(value.count, 8))
|
||||
}
|
||||
let tail = value.suffix(4)
|
||||
return "\(first) •••• \(tail)"
|
||||
}
|
||||
|
||||
private func matches(_ key: String, _ keys: Set<String>) -> Bool {
|
||||
let parts = key.split(separator: "_").map(String.init)
|
||||
let suffixes = [key] + (1...2).compactMap { count in
|
||||
parts.count >= count ? parts.suffix(count).joined(separator: "_") : nil
|
||||
}
|
||||
return suffixes.contains { keys.contains($0) }
|
||||
}
|
||||
|
||||
private struct SensitiveValueSheet: View {
|
||||
let values: [(title: String, value: String)]
|
||||
@Binding var isRevealed: Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
if values.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "lock.open",
|
||||
title: "暂无敏感值",
|
||||
message: "设备数据加载后,这里会集中显示 IMEI、ICCID 等信息。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Toggle(isOn: $isRevealed) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("显示敏感值")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("离开页面或手动关闭后自动隐藏。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.tint(AITheme.primary)
|
||||
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.6))
|
||||
|
||||
ForEach(Array(values.enumerated()), id: \.offset) { index, item in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.6))
|
||||
}
|
||||
AIKeyValueRow(
|
||||
item.title,
|
||||
value: isRevealed ? item.value : masked(item.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("敏感值")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("完成") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium, .large])
|
||||
}
|
||||
|
||||
private func masked(_ value: String) -> String {
|
||||
guard let first = value.first, value.count > 6 else {
|
||||
return String(repeating: "•", count: min(value.count, 8))
|
||||
}
|
||||
let tail = value.suffix(4)
|
||||
return "\(first) •••• \(tail)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
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")
|
||||
if targets.isEmpty {
|
||||
Text("先在节点列表勾选要归类的节点。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
} else {
|
||||
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 {
|
||||
// 与节点卡片的标签行同一排法:胶囊按标签长度取宽。栅格会把每枚拉到
|
||||
// 同一列宽,短标签右侧空出一片,看起来像控件没加载完。
|
||||
AIFlowLayout(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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 || targets.isEmpty)
|
||||
Button(isBusy ? "处理中" : "添加") { submitDraft() }
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fixedWidth: 84))
|
||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isBusy || targets.isEmpty)
|
||||
}
|
||||
// 没有勾选节点时这个表单按下去只会清空输入,先说清楚为什么不能动。
|
||||
if targets.isEmpty {
|
||||
Text("勾选节点后即可新建标签并批量贴上。")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(AITheme.faint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 isInert: Bool { total == 0 }
|
||||
|
||||
private var stateText: String {
|
||||
if isInert { return "" }
|
||||
if isComplete { return "全部" }
|
||||
if isPartial { return "\(covered)/\(total)" }
|
||||
return "未贴"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if isInert {
|
||||
// 不可操作时不渲染 Button:系统禁用态会把整枚胶囊压成灰色,
|
||||
// 看起来像加载失败,而标签本身是需要读清楚的内容。
|
||||
label
|
||||
.accessibilityLabel("标签 \(title),勾选节点后可贴上")
|
||||
} else {
|
||||
Button(action: action) { label }
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isBusy)
|
||||
.accessibilityLabel(isComplete ? "从已选节点移除标签 \(title)" : "给已选节点贴上标签 \(title)")
|
||||
.accessibilityAddTraits(isComplete ? [.isButton, .isSelected] : .isButton)
|
||||
}
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: iconName)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.footnote.weight(isComplete || isPartial ? .semibold : .medium))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if stateText.isEmpty == false {
|
||||
Text(stateText)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.opacity(0.72)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 38)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(border)
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if isInert { return "tag" }
|
||||
if isComplete { return "checkmark.circle.fill" }
|
||||
if isPartial { return "circle.lefthalf.filled" }
|
||||
return "circle"
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
if isInert { return AITheme.ink }
|
||||
if isComplete { return AITheme.onPrimary }
|
||||
if isPartial { return AITheme.ink }
|
||||
return AITheme.muted
|
||||
}
|
||||
|
||||
private var background: Color {
|
||||
if isInert { return AITheme.tile }
|
||||
if isComplete { return AITheme.primary }
|
||||
if isPartial { return AITheme.tile }
|
||||
return AITheme.surface
|
||||
}
|
||||
|
||||
private var border: Color {
|
||||
if isInert { return AITheme.tileLine }
|
||||
if isComplete { return AITheme.primary }
|
||||
if isPartial { return AITheme.primary.opacity(0.42) }
|
||||
return AITheme.line
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class FleetViewModel: ObservableObject {
|
||||
struct Row: Equatable, Identifiable {
|
||||
let id: UUID
|
||||
var instance: Instance
|
||||
var status: ConnectionStatus
|
||||
var deviceName: String?
|
||||
var phoneNumber: String?
|
||||
var operatorName: String?
|
||||
var networkTechnology: String?
|
||||
var signalPercent: Int?
|
||||
var cpuPercent: Double?
|
||||
var memoryPercent: Double?
|
||||
var cpuCount: Int?
|
||||
var temperatureCelsius: Double?
|
||||
var batteryPercent: Double?
|
||||
var ipAddress: String?
|
||||
var latencyMilliseconds: Int?
|
||||
var connectivity: String?
|
||||
var firmwareVersion: String?
|
||||
var totalStorageBytes: Int64?
|
||||
var availableStorageBytes: Int64?
|
||||
var downloadRateBytesPerSecond: Double?
|
||||
var uploadRateBytesPerSecond: Double?
|
||||
var messagesToday: Int?
|
||||
var uptimeSeconds: Double?
|
||||
var chainValues: DeviceLinkState?
|
||||
var testing: Bool
|
||||
|
||||
var storageUsageRatio: Double? {
|
||||
guard let total = totalStorageBytes, let available = availableStorageBytes, total > 0 else {
|
||||
return nil
|
||||
}
|
||||
return Double(max(0, total - available)) / Double(total)
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID,
|
||||
instance: Instance,
|
||||
status: ConnectionStatus = .unknown,
|
||||
testing: Bool = true
|
||||
) {
|
||||
self.id = id
|
||||
self.instance = instance
|
||||
self.status = status
|
||||
self.testing = testing
|
||||
}
|
||||
}
|
||||
|
||||
@Published var rows: [Row] = []
|
||||
@Published var searchText = ""
|
||||
@Published var statusFilter: ConnectionStatus?
|
||||
@Published var selectedTagFilters: Set<String> = [] {
|
||||
didSet {
|
||||
let values = Array(selectedTagFilters).sorted()
|
||||
preferences.set(values, forKey: "fleet.selectedTagFilters")
|
||||
}
|
||||
}
|
||||
@Published var selectedIDs: Set<UUID> = []
|
||||
@Published var selectionMode = false
|
||||
@Published var isLoading = false
|
||||
@Published private(set) var lastRefresh: Date?
|
||||
@Published var errorMessage: String?
|
||||
@Published var showingForm = false
|
||||
@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 preferences: UserDefaults
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = nil,
|
||||
preferences: UserDefaults = .standard
|
||||
) {
|
||||
self.repository = repository
|
||||
self.probe = probe
|
||||
self.credentials = credentials
|
||||
self.deviceService = deviceService
|
||||
self.preferences = preferences
|
||||
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
|
||||
}
|
||||
|
||||
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
|
||||
func pruneTagFilters() {
|
||||
guard selectedTagFilters.isEmpty == false else { return }
|
||||
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
|
||||
if live != selectedTagFilters {
|
||||
selectedTagFilters = live
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
|
||||
let instances = try await repository.list(query)
|
||||
rows = instances.map {
|
||||
Row(id: $0.id, instance: $0, status: .unknown, testing: true)
|
||||
}
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for instance in instances {
|
||||
group.addTask { [probe, deviceService] in
|
||||
let result = try? await probe.test(instance)
|
||||
let summary = try? await deviceService?.summary(instance)
|
||||
await self.applyProbe(instance.id, result, summary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "无法刷新节点列表"
|
||||
}
|
||||
pruneTagFilters()
|
||||
lastRefresh = Date()
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func add(_ input: InstanceInput) async throws -> Instance {
|
||||
let created = try await repository.create(input)
|
||||
if let password = input.password, !password.isEmpty {
|
||||
try credentials.set(instanceID: created.id, password: password)
|
||||
}
|
||||
showingForm = false
|
||||
await refresh()
|
||||
return created
|
||||
}
|
||||
|
||||
func update(_ id: UUID, _ input: InstanceInput) async throws {
|
||||
guard let original = rows.first(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
_ = try await repository.update(
|
||||
id,
|
||||
expectedRevision: original.instance.configRevision,
|
||||
InstancePatch(
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
tags: input.tags,
|
||||
password: input.password
|
||||
)
|
||||
)
|
||||
if let password = input.password, !password.isEmpty {
|
||||
try credentials.set(instanceID: id, password: password)
|
||||
}
|
||||
showingForm = false
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func delete(_ ids: [UUID]) async {
|
||||
for id in ids {
|
||||
if let row = rows.first(where: { $0.id == id }) {
|
||||
do {
|
||||
try await repository.delete(id, expectedRevision: row.instance.configRevision)
|
||||
try credentials.remove(instanceID: id)
|
||||
} catch {
|
||||
errorMessage = "删除 \(row.instance.name) 失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
selectedIDs.removeAll()
|
||||
selectionMode = false
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func deleteSelected() async {
|
||||
await delete(Array(selectedIDs))
|
||||
}
|
||||
|
||||
/// 批量给选中的节点增删标签:标签是唯一的归类手段,所以这里必须能成批操作。
|
||||
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(tags: updated)
|
||||
)
|
||||
} catch {
|
||||
failures += 1
|
||||
errorMessage = "更新 \(row.instance.name) 标签失败"
|
||||
}
|
||||
}
|
||||
if failures > 0, errorMessage == nil {
|
||||
errorMessage = "部分节点标签更新失败"
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
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(
|
||||
_ id: UUID,
|
||||
_ result: ConnectionResult?,
|
||||
_ summary: DeviceSummary?
|
||||
) {
|
||||
guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
|
||||
rows[index].status = result?.status ?? .unknown
|
||||
if let summary {
|
||||
rows[index].deviceName = summary.deviceName
|
||||
rows[index].phoneNumber = summary.phoneNumber
|
||||
rows[index].operatorName = summary.operatorName
|
||||
rows[index].networkTechnology = summary.networkTechnology
|
||||
rows[index].signalPercent = summary.signalPercent
|
||||
rows[index].cpuPercent = summary.cpuPercent
|
||||
rows[index].memoryPercent = summary.memoryPercent
|
||||
rows[index].cpuCount = summary.cpuCount
|
||||
rows[index].temperatureCelsius = summary.temperatureCelsius
|
||||
rows[index].batteryPercent = summary.batteryPercent
|
||||
rows[index].ipAddress = summary.ipAddress
|
||||
rows[index].latencyMilliseconds = summary.latencyMilliseconds
|
||||
rows[index].connectivity = summary.connectivity
|
||||
rows[index].firmwareVersion = summary.firmwareVersion
|
||||
rows[index].totalStorageBytes = summary.totalStorageBytes
|
||||
rows[index].availableStorageBytes = summary.availableStorageBytes
|
||||
rows[index].downloadRateBytesPerSecond = summary.downloadRateBytesPerSecond
|
||||
rows[index].uploadRateBytesPerSecond = summary.uploadRateBytesPerSecond
|
||||
rows[index].messagesToday = summary.messagesToday
|
||||
rows[index].uptimeSeconds = summary.uptimeSeconds
|
||||
rows[index].chainValues = summary.linkState
|
||||
}
|
||||
if rows[index].latencyMilliseconds == nil {
|
||||
rows[index].latencyMilliseconds = result?.requestLatencyMilliseconds
|
||||
}
|
||||
rows[index].testing = false
|
||||
}
|
||||
|
||||
/// 列表顺序:状态差的排前面,同组内按名称,扫读时问题节点一定在第一屏。
|
||||
var visibleRows: [Row] {
|
||||
rows.filter { row in
|
||||
if let statusFilter, row.status != statusFilter {
|
||||
return false
|
||||
}
|
||||
if selectedTagFilters.isEmpty == false {
|
||||
let tags = Set(row.instance.tags.map { Self.normalizedTag($0) })
|
||||
let needles = Set(selectedTagFilters.map { Self.normalizedTag($0) })
|
||||
guard needles.isSubset(of: tags) else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
guard !searchText.isEmpty else { return true }
|
||||
let haystacks: [String] = [
|
||||
row.instance.name,
|
||||
row.instance.origin.absoluteString,
|
||||
row.instance.tags.joined(separator: " "),
|
||||
row.operatorName ?? "",
|
||||
row.phoneNumber ?? "",
|
||||
row.networkTechnology ?? "",
|
||||
row.deviceName ?? ""
|
||||
]
|
||||
let strings: [String] = haystacks
|
||||
return strings.contains {
|
||||
$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] {
|
||||
Array(tagCounts.keys).sorted { $0.localizedCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
var tagCounts: [String: Int] {
|
||||
var counts: [String: Int] = [:]
|
||||
for row in rows {
|
||||
for tag in Set(row.instance.tags.map(Self.normalizedTag)) where tag.isEmpty == false {
|
||||
counts[tag, default: 0] += 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
func countForTagFilter(_ tag: String) -> Int {
|
||||
tagCounts[Self.normalizedTag(tag)] ?? 0
|
||||
}
|
||||
|
||||
func isTagFilterActive(_ tag: String) -> Bool {
|
||||
selectedTagFilters.contains(Self.normalizedTag(tag))
|
||||
}
|
||||
|
||||
/// 选中的这批节点里,有多少台已经带上这个标签:决定胶囊显示"全部/部分/未标记"。
|
||||
func 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 }
|
||||
if selectedTagFilters.contains(normalized) {
|
||||
selectedTagFilters.remove(normalized)
|
||||
} else {
|
||||
selectedTagFilters.insert(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func clearTagFilters() {
|
||||
guard selectedTagFilters.isEmpty == false else { return }
|
||||
selectedTagFilters.removeAll()
|
||||
}
|
||||
|
||||
private static func normalizedTag(_ tag: String) -> String {
|
||||
tag.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
protocol DeviceSummaryService {
|
||||
func summary(_ instance: Instance) async throws -> DeviceSummary
|
||||
}
|
||||
|
||||
extension DeviceService: DeviceSummaryService {}
|
||||
@@ -0,0 +1,150 @@
|
||||
import SwiftUI
|
||||
|
||||
struct InstanceFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let instance: Instance?
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tags: [String] = []
|
||||
@State private var tagDraft = ""
|
||||
@State private var password = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSaving = false
|
||||
|
||||
init(model: FleetViewModel, instance: Instance? = nil) {
|
||||
self.model = model
|
||||
self.instance = instance
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_tags = State(initialValue: instance?.tags ?? [])
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !originText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
tagEditor
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
|
||||
Button {
|
||||
save()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isSaving {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(isSaving ? "保存中" : "保存实例")
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSave || isSaving)
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
|
||||
#if os(iOS)
|
||||
// 表单表统一用行内标题:大标题会在卡片上方留出一整条空白,
|
||||
// 而「批量标签」等其它表已经是行内样式。
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var tagEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("标签")
|
||||
TagEditorView(tags: $tags, suggestions: model.allTags)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
placeholder: String,
|
||||
secure: Bool = false
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel(title)
|
||||
if secure {
|
||||
SecureField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.textContentType(.password)
|
||||
.accessibilityLabel(title)
|
||||
} else {
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel(title)
|
||||
.submitLabel(.done)
|
||||
#if os(iOS)
|
||||
.keyboardType(title == "地址" ? .URL : .default)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard !isSaving else { return }
|
||||
isSaving = true
|
||||
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
origin.scheme == "http" || origin.scheme == "https",
|
||||
origin.host != nil
|
||||
else {
|
||||
validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。"
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
let input = InstanceInput(
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
tags: tags,
|
||||
password: password.isEmpty ? nil : password
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
if let instance {
|
||||
try await model.update(instance.id, input)
|
||||
} else {
|
||||
try await model.add(input)
|
||||
}
|
||||
} catch {
|
||||
validationMessage = "保存失败,请检查地址是否重复。"
|
||||
}
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 标签编辑器:已选标签用可删除胶囊展示,历史标签可点选复用。
|
||||
struct TagEditorView: View {
|
||||
@Binding var tags: [String]
|
||||
let suggestions: [String]
|
||||
@State private var draft = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("新标签,回车添加", text: $draft)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.submitLabel(.done)
|
||||
#endif
|
||||
.onSubmit { commit(draft) }
|
||||
|
||||
Button {
|
||||
commit(draft)
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
// 加号按钮和左边的输入框同一高度、同一圆角,两个控件才读得出是一组。
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(width: AITheme.inputHeight, height: AITheme.inputHeight)
|
||||
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
|
||||
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
|
||||
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(canAdd == false)
|
||||
.accessibilityLabel("添加标签")
|
||||
}
|
||||
|
||||
if tags.isEmpty == false || suggestions.isEmpty == false {
|
||||
// 已选与可选分成两条横向胶囊带:自定义流式布局在弹层里拿到的宽度提案不稳定,
|
||||
// 会出现高度算少、胶囊压到下一个字段的问题,横向滚动条带既稳又和节点页一致。
|
||||
if tags.isEmpty == false {
|
||||
chipStrip(ids: tags.map { "sel-\($0)" }) { index in
|
||||
removableTag(tags[index])
|
||||
}
|
||||
}
|
||||
if availableSuggestions.isEmpty == false {
|
||||
chipStrip(ids: availableSuggestions.map { "sug-\($0)" }) { index in
|
||||
suggestionTag(availableSuggestions[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("标签")
|
||||
}
|
||||
|
||||
private func chipStrip<Item: View>(
|
||||
ids: [String],
|
||||
@ViewBuilder content: @escaping (Int) -> Item
|
||||
) -> some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(Array(ids.enumerated()), id: \.element) { index, _ in
|
||||
content(index)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.accessibilityHidden(false)
|
||||
}
|
||||
|
||||
private var trimmedDraft: String {
|
||||
draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var canAdd: Bool {
|
||||
trimmedDraft.isEmpty == false
|
||||
}
|
||||
|
||||
private var availableSuggestions: [String] {
|
||||
let taken = Set(tags.map(Self.key))
|
||||
return suggestions
|
||||
.filter { taken.contains(Self.key($0)) == false }
|
||||
.prefix(10)
|
||||
.map { $0 }
|
||||
}
|
||||
|
||||
private func removableTag(_ tag: String) -> some View {
|
||||
Button {
|
||||
tags.removeAll { Self.key($0) == Self.key(tag) }
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Text(tag)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.opacity(0.65)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 32)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.background(AITheme.control)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous).strokeBorder(AITheme.line)
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("移除标签 \(tag)")
|
||||
}
|
||||
|
||||
private func suggestionTag(_ tag: String) -> some View {
|
||||
Button {
|
||||
add(tag)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text(tag)
|
||||
.font(.footnote.weight(.medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 11)
|
||||
.frame(height: 32)
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.background(AITheme.primary.opacity(0.09))
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous).strokeBorder(AITheme.primary.opacity(0.24))
|
||||
}
|
||||
.contentShape(Capsule(style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("使用已有标签 \(tag)")
|
||||
}
|
||||
|
||||
private func commit(_ raw: String) {
|
||||
let parts = raw
|
||||
.split(whereSeparator: { $0 == "," || $0 == "," || $0 == "\n" })
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
parts.forEach(add)
|
||||
draft = ""
|
||||
}
|
||||
|
||||
private func add(_ tag: String) {
|
||||
let value = tag.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard value.isEmpty == false else { return }
|
||||
guard tags.contains(where: { Self.key($0) == Self.key(value) }) == false else { return }
|
||||
tags.append(value)
|
||||
}
|
||||
|
||||
private static func key(_ value: String) -> String {
|
||||
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import Foundation
|
||||
|
||||
public enum RuntimeLogSource: String, Codable, Equatable, Sendable, CaseIterable {
|
||||
case delivery
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .delivery: return "节点运行"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum RuntimeLogLevel: String, Codable, Equatable, Sendable, CaseIterable {
|
||||
case info
|
||||
case warning
|
||||
case error
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .info: return "信息"
|
||||
case .warning: return "警告"
|
||||
case .error: return "错误"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ConnectionOutcome: String, Codable, Equatable, Sendable, CaseIterable {
|
||||
case success
|
||||
case stale
|
||||
case failed
|
||||
case unsupported
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .success: return "成功"
|
||||
case .stale: return "需登录"
|
||||
case .failed: return "失败"
|
||||
case .unsupported: return "不支持"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RuntimeLogEntry: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let source: RuntimeLogSource
|
||||
public let level: RuntimeLogLevel
|
||||
public let message: String
|
||||
public let eventType: String?
|
||||
public let instanceID: UUID?
|
||||
public let instanceName: String?
|
||||
public let occurredAt: Date
|
||||
public let code: String?
|
||||
public let durationMs: Int?
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
source: RuntimeLogSource,
|
||||
level: RuntimeLogLevel,
|
||||
message: String,
|
||||
eventType: String? = nil,
|
||||
instanceID: UUID? = nil,
|
||||
instanceName: String? = nil,
|
||||
occurredAt: Date,
|
||||
code: String? = nil,
|
||||
durationMs: Int? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.source = source
|
||||
self.level = level
|
||||
self.message = message
|
||||
self.eventType = eventType
|
||||
self.instanceID = instanceID
|
||||
self.instanceName = instanceName
|
||||
self.occurredAt = occurredAt
|
||||
self.code = code
|
||||
self.durationMs = durationMs
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionLogEntry: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public let instanceID: UUID
|
||||
public let instanceName: String
|
||||
public let outcome: ConnectionOutcome
|
||||
public let state: String
|
||||
public let errorCode: String?
|
||||
public let httpStatus: Int?
|
||||
public let durationMs: Int
|
||||
public let observedAt: Date
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
instanceID: UUID,
|
||||
instanceName: String,
|
||||
outcome: ConnectionOutcome,
|
||||
state: String,
|
||||
errorCode: String? = nil,
|
||||
httpStatus: Int? = nil,
|
||||
durationMs: Int,
|
||||
observedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.instanceID = instanceID
|
||||
self.instanceName = instanceName
|
||||
self.outcome = outcome
|
||||
self.state = state
|
||||
self.errorCode = errorCode
|
||||
self.httpStatus = httpStatus
|
||||
self.durationMs = durationMs
|
||||
self.observedAt = observedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct LogCapabilitySummary: Equatable, Sendable {
|
||||
public let total: Int
|
||||
public let supported: Int
|
||||
public let unsupported: Int
|
||||
public let authRequired: Int
|
||||
public let degraded: Int
|
||||
public let unknown: Int
|
||||
|
||||
public init(
|
||||
total: Int,
|
||||
supported: Int,
|
||||
unsupported: Int,
|
||||
authRequired: Int,
|
||||
degraded: Int,
|
||||
unknown: Int
|
||||
) {
|
||||
self.total = total
|
||||
self.supported = supported
|
||||
self.unsupported = unsupported
|
||||
self.authRequired = authRequired
|
||||
self.degraded = degraded
|
||||
self.unknown = unknown
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionSummary: Equatable, Sendable {
|
||||
public let total: Int
|
||||
public let success: Int
|
||||
public let failed: Int
|
||||
public let averageDurationMs: Int
|
||||
public let availabilityPercent: Int
|
||||
}
|
||||
|
||||
public struct InstanceDiagnostic: Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public let name: String
|
||||
public let origin: String
|
||||
public let tags: [String]
|
||||
public let state: ConnectionStatus
|
||||
public let healthStatus: String?
|
||||
public let version: String?
|
||||
public let platform: String?
|
||||
public let observedAt: Date
|
||||
public let featureStatus: [FeatureStatus]
|
||||
public let connection: ConnectionSummary?
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
name: String,
|
||||
origin: String,
|
||||
tags: [String],
|
||||
state: ConnectionStatus,
|
||||
healthStatus: String?,
|
||||
version: String?,
|
||||
platform: String?,
|
||||
observedAt: Date,
|
||||
featureStatus: [FeatureStatus],
|
||||
connection: ConnectionSummary?
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.state = state
|
||||
self.healthStatus = healthStatus
|
||||
self.version = version
|
||||
self.platform = platform
|
||||
self.observedAt = observedAt
|
||||
self.featureStatus = featureStatus
|
||||
self.connection = connection
|
||||
}
|
||||
}
|
||||
|
||||
public struct FeatureStatus: Equatable, Identifiable, Sendable {
|
||||
public enum State: Equatable, Sendable {
|
||||
case ready
|
||||
case requiresLogin
|
||||
case unavailable
|
||||
}
|
||||
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let state: State
|
||||
|
||||
public init(name: String, state: State) {
|
||||
self.name = name
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
|
||||
public struct LogSnapshot: Equatable, Sendable {
|
||||
public let observedAt: Date
|
||||
public let runtime: [RuntimeLogEntry]
|
||||
public let counts: [RuntimeLogSource: Int]
|
||||
public let connections: [ConnectionLogEntry]
|
||||
public let diagnostics: [InstanceDiagnostic]
|
||||
}
|
||||
|
||||
public protocol LogStore: Sendable {
|
||||
func runtimeLogs() async throws -> [RuntimeLogEntry]
|
||||
func connectionLogs() async throws -> [ConnectionLogEntry]
|
||||
func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws
|
||||
func pruneConnections(before: Date) async throws -> Int
|
||||
func clearRuntimeLogs() async throws -> Int
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import Foundation
|
||||
|
||||
public struct LogCenterService: Sendable {
|
||||
private struct ProbeOutcome: Sendable {
|
||||
var status: ConnectionStatus
|
||||
var outcome: ConnectionOutcome
|
||||
var state: String
|
||||
var errorCode: String?
|
||||
var httpStatus: Int?
|
||||
var durationMs: Int
|
||||
var message: String
|
||||
var version: String?
|
||||
var platform: String?
|
||||
}
|
||||
|
||||
private struct InstanceCollection: Sendable {
|
||||
var runtime: [RuntimeLogEntry] = []
|
||||
var connections: [ConnectionLogEntry] = []
|
||||
var diagnostic: InstanceDiagnostic?
|
||||
}
|
||||
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
private let store: any LogStore
|
||||
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil,
|
||||
store: any LogStore
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
self.store = store
|
||||
}
|
||||
|
||||
public func refresh(_ instances: [Instance]) async throws -> LogSnapshot {
|
||||
var collected: [InstanceCollection] = []
|
||||
await withTaskGroup(of: InstanceCollection.self) { group in
|
||||
for instance in instances {
|
||||
group.addTask { await self.collect(instance) }
|
||||
}
|
||||
for await value in group {
|
||||
collected.append(value)
|
||||
}
|
||||
}
|
||||
|
||||
collected.sort { ($0.diagnostic?.name ?? "") < ($1.diagnostic?.name ?? "") }
|
||||
let runtime = collected.flatMap(\.runtime).sorted { $0.occurredAt > $1.occurredAt }
|
||||
let connections = collected.flatMap(\.connections).sorted { $0.observedAt > $1.observedAt }
|
||||
let diagnostics = collected.compactMap(\.diagnostic)
|
||||
try await store.append(runtime: runtime, connections: connections)
|
||||
|
||||
var counts: [RuntimeLogSource: Int] = [:]
|
||||
for source in RuntimeLogSource.allCases {
|
||||
counts[source] = runtime.filter { $0.source == source }.count
|
||||
}
|
||||
return LogSnapshot(
|
||||
observedAt: Date(),
|
||||
runtime: runtime,
|
||||
counts: counts,
|
||||
connections: connections,
|
||||
diagnostics: diagnostics
|
||||
)
|
||||
}
|
||||
|
||||
private func collect(_ instance: Instance) async -> InstanceCollection {
|
||||
var result = InstanceCollection()
|
||||
let probe = await probe(instance)
|
||||
var modules: [String: Result<Bool, Error>] = [
|
||||
"connection": .success(true),
|
||||
"device": await moduleResult(instance, .device)
|
||||
]
|
||||
if probe.outcome == .failed {
|
||||
modules["connection"] = .failure(UpstreamError.notSupported)
|
||||
modules["device"] = .failure(UpstreamError.notSupported)
|
||||
}
|
||||
result.connections.append(ConnectionLogEntry(
|
||||
id: "probe-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
outcome: probe.outcome,
|
||||
state: probe.state,
|
||||
errorCode: probe.errorCode,
|
||||
httpStatus: probe.httpStatus,
|
||||
durationMs: probe.durationMs,
|
||||
observedAt: Date()
|
||||
))
|
||||
if probe.outcome == .failed || probe.outcome == .stale {
|
||||
result.runtime.append(RuntimeLogEntry(
|
||||
id: "connection-\(instance.id.uuidString)-\(Int(Date().timeIntervalSince1970 * 1000))",
|
||||
source: .delivery,
|
||||
level: probe.outcome == .failed ? .error : .warning,
|
||||
message: probe.message,
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
occurredAt: Date(),
|
||||
code: probe.errorCode,
|
||||
durationMs: probe.durationMs
|
||||
))
|
||||
}
|
||||
|
||||
modules["status"] = .success(true)
|
||||
result.diagnostic = diagnostic(
|
||||
instance,
|
||||
probe: probe,
|
||||
modules: modules,
|
||||
connections: result.connections
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
private func probe(_ instance: Instance) async -> ProbeOutcome {
|
||||
let startedAt = Date()
|
||||
do {
|
||||
let response = try await client.send(.health, baseURL: instance.origin, cookie: nil)
|
||||
let duration = elapsed(startedAt)
|
||||
let root = (try? JSONSerialization.jsonObject(with: response.data)) as? [String: Any] ?? [:]
|
||||
return ProbeOutcome(
|
||||
status: .online,
|
||||
outcome: .success,
|
||||
state: "online",
|
||||
errorCode: nil,
|
||||
httpStatus: response.status,
|
||||
durationMs: duration,
|
||||
message: "节点连接成功",
|
||||
version: Self.text(root["version"]),
|
||||
platform: Self.text(root["platform"])
|
||||
)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
return ProbeOutcome(
|
||||
status: .authRequired,
|
||||
outcome: .stale,
|
||||
state: "auth_required",
|
||||
errorCode: "UNAUTHORIZED",
|
||||
httpStatus: 401,
|
||||
durationMs: elapsed(startedAt),
|
||||
message: "节点需要登录",
|
||||
version: nil,
|
||||
platform: nil
|
||||
)
|
||||
} catch UpstreamError.timeout {
|
||||
return ProbeOutcome(
|
||||
status: .offline,
|
||||
outcome: .failed,
|
||||
state: "unreachable",
|
||||
errorCode: "TIMEOUT",
|
||||
httpStatus: nil,
|
||||
durationMs: elapsed(startedAt),
|
||||
message: "节点连接超时",
|
||||
version: nil,
|
||||
platform: nil
|
||||
)
|
||||
} catch {
|
||||
return ProbeOutcome(
|
||||
status: .degraded,
|
||||
outcome: .failed,
|
||||
state: "unreachable",
|
||||
errorCode: "TRANSPORT",
|
||||
httpStatus: nil,
|
||||
durationMs: elapsed(startedAt),
|
||||
message: "节点连接失败",
|
||||
version: nil,
|
||||
platform: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func moduleResult(
|
||||
_ instance: Instance,
|
||||
_ route: UpstreamRoute
|
||||
) async -> Result<Bool, Error> {
|
||||
do {
|
||||
_ = try await request(route, instance)
|
||||
return .success(true)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func diagnostic(
|
||||
_ instance: Instance,
|
||||
probe: ProbeOutcome,
|
||||
modules: [String: Result<Bool, Error>],
|
||||
connections: [ConnectionLogEntry]
|
||||
) -> InstanceDiagnostic {
|
||||
let featureStatus = modules
|
||||
.sorted { $0.key < $1.key }
|
||||
.map { key, result -> FeatureStatus in
|
||||
let name = key == "connection" ? "连接" : key == "status" ? "状态" : "设备"
|
||||
switch result {
|
||||
case .success:
|
||||
return FeatureStatus(name: name, state: .ready)
|
||||
case .failure(UpstreamError.unauthorized), .failure(UpstreamError.forbidden):
|
||||
return FeatureStatus(name: name, state: .requiresLogin)
|
||||
case .failure:
|
||||
return FeatureStatus(name: name, state: .unavailable)
|
||||
}
|
||||
}
|
||||
let success = connections.filter { $0.outcome == .success }.count
|
||||
let failed = connections.filter { $0.outcome == .failed }.count
|
||||
let average = connections.isEmpty
|
||||
? 0
|
||||
: connections.map(\.durationMs).reduce(0, +) / connections.count
|
||||
return InstanceDiagnostic(
|
||||
id: instance.id,
|
||||
name: instance.name,
|
||||
origin: instance.origin.absoluteString,
|
||||
tags: instance.tags,
|
||||
state: probe.status,
|
||||
healthStatus: Self.healthLabel(probe),
|
||||
version: probe.version,
|
||||
platform: probe.platform,
|
||||
observedAt: Date(),
|
||||
featureStatus: featureStatus,
|
||||
connection: ConnectionSummary(
|
||||
total: connections.count,
|
||||
success: success,
|
||||
failed: failed,
|
||||
averageDurationMs: average,
|
||||
availabilityPercent: connections.isEmpty ? 0 : success * 100 / connections.count
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
let service = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await service.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
private func elapsed(_ startedAt: Date) -> Int {
|
||||
max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
|
||||
}
|
||||
|
||||
private static func healthLabel(_ probe: ProbeOutcome) -> String {
|
||||
switch probe.state {
|
||||
case "online", "UP", "ok": return "在线"
|
||||
case "auth_required": return "需登录"
|
||||
case "unreachable": return probe.errorCode == "TIMEOUT" ? "超时" : "不可达"
|
||||
default: return probe.state
|
||||
}
|
||||
}
|
||||
|
||||
private static func text(_ value: Any?) -> String? {
|
||||
guard let value = value as? String, !value.isEmpty, value.count <= 512 else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
private static func date(_ value: Any?) -> Date {
|
||||
guard let text = value as? String else { return Date() }
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let parsed = formatter.date(from: text) { return parsed }
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: text) ?? Date()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LogsView: View {
|
||||
private enum Tab: Hashable {
|
||||
case runtime
|
||||
case connections
|
||||
case diagnostics
|
||||
}
|
||||
|
||||
@StateObject private var model: LogsViewModel
|
||||
private let autoRefreshOnAppear: Bool
|
||||
@State private var tab: Tab = .runtime
|
||||
@State private var showsPruneDialog = false
|
||||
|
||||
init(model: LogsViewModel? = nil) {
|
||||
let resolvedModel = model ?? DefaultContainer.makeLogsModel(autoRefreshOnAppear: false)
|
||||
_model = StateObject(wrappedValue: resolvedModel)
|
||||
self.autoRefreshOnAppear = resolvedModel.autoRefreshOnAppear
|
||||
}
|
||||
|
||||
private var isUnavailable: Bool {
|
||||
model.snapshot == nil && model.errorMessage != nil && !model.isLoading
|
||||
}
|
||||
|
||||
/// 空列表配一排筛选器只是噪音,有内容才给筛选入口。
|
||||
private var hasRowsForTab: Bool {
|
||||
switch tab {
|
||||
case .runtime: return model.filteredRuntime.isEmpty == false
|
||||
case .connections: return model.filteredConnections.isEmpty == false
|
||||
case .diagnostics: return model.filteredDiagnostics.isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if isUnavailable {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "arrow.triangle.2.circlepath",
|
||||
title: "未能读取日志",
|
||||
message: model.errorMessage ?? "所有节点都没有返回日志数据。",
|
||||
actionTitle: "重试"
|
||||
) {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
}
|
||||
} else if model.snapshot == nil && model.isLoading {
|
||||
AILoadingCard("正在同步日志数据")
|
||||
} else {
|
||||
stats
|
||||
|
||||
AISegmentedControl(
|
||||
"日志视图",
|
||||
selection: $tab,
|
||||
options: [
|
||||
(value: Tab.runtime, title: "运行日志"),
|
||||
(value: Tab.connections, title: "连接日志"),
|
||||
(value: Tab.diagnostics, title: "设备诊断"),
|
||||
]
|
||||
)
|
||||
|
||||
if hasRowsForTab {
|
||||
filterBar
|
||||
}
|
||||
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
|
||||
switch tab {
|
||||
case .runtime:
|
||||
runtimeSection
|
||||
case .connections:
|
||||
connectionSection
|
||||
case .diagnostics:
|
||||
diagnosticSection
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"清理 7 天前的连接日志?操作不可恢复。",
|
||||
isPresented: $showsPruneDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("清理连接日志", role: .destructive) {
|
||||
Task {
|
||||
_ = await model.pruneConnections(olderThanDays: 7)
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
if model.snapshot == nil && autoRefreshOnAppear {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。
|
||||
private var stats: some View {
|
||||
let runtime = model.snapshot?.runtime.count ?? 0
|
||||
let connections = model.snapshot?.connections.count ?? 0
|
||||
let diagnostics = model.snapshot?.diagnostics.count ?? 0
|
||||
|
||||
return Group {
|
||||
if runtime + connections + diagnostics > 0 {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
|
||||
) {
|
||||
AIStatCard(
|
||||
title: "运行日志", value: "\(runtime)", systemImage: "list.bullet", tint: AITheme.primary)
|
||||
AIStatCard(
|
||||
title: "连接探测", value: "\(connections)", systemImage: "dot.radiowaves.left.and.right",
|
||||
tint: model.offlineCount == 0 ? AITheme.success : AITheme.warning)
|
||||
AIStatCard(
|
||||
title: "设备诊断", value: "\(diagnostics)", systemImage: "iphone.gen3", tint: AITheme.ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: 8) {
|
||||
AISearchField("搜索日志、错误码或实例", text: $model.searchText, minHeight: AITheme.compactControlHeight)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface(compact: true)
|
||||
|
||||
if tab == .runtime {
|
||||
HStack(spacing: 8) {
|
||||
Picker("来源", selection: $model.selectedSource) {
|
||||
Text("全部来源").tag(Optional<RuntimeLogSource>.none)
|
||||
ForEach(RuntimeLogSource.allCases, id: \.self) { source in
|
||||
Text(source.label).tag(Optional(source))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface(compact: true)
|
||||
|
||||
Picker("级别", selection: $model.selectedLevel) {
|
||||
Text("全部级别").tag(Optional<RuntimeLogLevel>.none)
|
||||
ForEach(RuntimeLogLevel.allCases, id: \.self) { level in
|
||||
Text(level.label).tag(Optional(level))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface(compact: true)
|
||||
}
|
||||
} else if tab == .connections {
|
||||
Picker("结果", selection: $model.selectedOutcome) {
|
||||
Text("全部结果").tag(Optional<ConnectionOutcome>.none)
|
||||
ForEach(ConnectionOutcome.allCases, id: \.self) { outcome in
|
||||
Text(outcome.label).tag(Optional(outcome))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface(compact: true)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: tab)
|
||||
}
|
||||
|
||||
private var runtimeSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
|
||||
if model.filteredRuntime.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。")
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredRuntime) { entry in
|
||||
AIListCard(
|
||||
title: entry.message,
|
||||
subtitle: "\(entry.instanceName ?? "未知实例") · \(entry.occurredAt.formatted())",
|
||||
status: entry.level.label,
|
||||
statusTint: levelTint(entry.level),
|
||||
details: {
|
||||
var values = [entry.source.label]
|
||||
if let duration = entry.durationMs {
|
||||
values.append("耗时 \(duration) ms")
|
||||
}
|
||||
return values
|
||||
}()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"连接探测记录",
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
detail: "\(model.filteredConnections.count) 条",
|
||||
actionTitle: "清理",
|
||||
actionSystemImage: "trash",
|
||||
actionKind: .danger
|
||||
) {
|
||||
showsPruneDialog = true
|
||||
}
|
||||
if model.filteredConnections.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "dot.radiowaves.left.and.right", title: "暂无连接日志", message: "连接状态探测后会在这里记录。"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredConnections) { entry in
|
||||
AIListCard(
|
||||
title: entry.instanceName,
|
||||
subtitle: "\(entry.observedAt.formatted()) · \(entry.state)",
|
||||
status: entry.outcome.label,
|
||||
statusTint: outcomeTint(entry.outcome),
|
||||
details: [
|
||||
"HTTP \(entry.httpStatus.map(String.init) ?? "—") · \(entry.durationMs) ms",
|
||||
entry.errorCode ?? "无错误码",
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var diagnosticSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(
|
||||
"设备诊断", systemImage: "iphone.gen3", detail: "\(model.filteredDiagnostics.count) 台")
|
||||
if model.filteredDiagnostics.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(systemImage: "iphone.gen3", title: "暂无设备诊断", message: "已配置实例的设备状态会显示在这里。")
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredDiagnostics) { diagnostic in
|
||||
AIListCard(
|
||||
title: diagnostic.name,
|
||||
status: diagnostic.healthStatus ?? "未知",
|
||||
statusTint: diagnostic.state == .online ? AITheme.success : AITheme.warning,
|
||||
details: [
|
||||
diagnostic.origin,
|
||||
"版本 \(diagnostic.version ?? "未知") · \(diagnostic.platform ?? "未知平台")",
|
||||
]
|
||||
) {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.adaptive(minimum: 82), spacing: 6)],
|
||||
alignment: .leading,
|
||||
spacing: 6
|
||||
) {
|
||||
ForEach(diagnostic.featureStatus) { feature in
|
||||
AITag(feature.name, tint: featureTint(feature.state))
|
||||
}
|
||||
if let connection = diagnostic.connection, connection.total > 1 {
|
||||
AITag(
|
||||
"延迟 \(connection.averageDurationMs) ms",
|
||||
tint: connection.averageDurationMs <= 1500 ? AITheme.success : AITheme.warning
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func levelTint(_ level: RuntimeLogLevel) -> Color {
|
||||
switch level {
|
||||
case .info: return AITheme.success
|
||||
case .warning: return AITheme.warning
|
||||
case .error: return AITheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
private func outcomeTint(_ outcome: ConnectionOutcome) -> Color {
|
||||
switch outcome {
|
||||
case .success: return AITheme.success
|
||||
case .stale: return AITheme.warning
|
||||
case .failed: return AITheme.danger
|
||||
case .unsupported: return AITheme.muted
|
||||
}
|
||||
}
|
||||
|
||||
private func featureTint(_ state: FeatureStatus.State) -> Color {
|
||||
switch state {
|
||||
case .ready: return AITheme.success
|
||||
case .requiresLogin: return AITheme.warning
|
||||
case .unavailable: return AITheme.muted
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LogsViewModel: ObservableObject {
|
||||
@Published private(set) var snapshot: LogSnapshot?
|
||||
@Published private(set) var instances: [Instance] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
|
||||
@Published var selectedSource: RuntimeLogSource?
|
||||
@Published var selectedLevel: RuntimeLogLevel?
|
||||
@Published var selectedOutcome: ConnectionOutcome?
|
||||
@Published var selectedInstanceID: UUID?
|
||||
@Published var searchText = ""
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let service: LogCenterService
|
||||
private let store: any LogStore
|
||||
let autoRefreshOnAppear: Bool
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
service: LogCenterService,
|
||||
store: any LogStore,
|
||||
autoRefreshOnAppear: Bool = true
|
||||
) {
|
||||
self.repository = repository
|
||||
self.service = service
|
||||
self.store = store
|
||||
self.autoRefreshOnAppear = autoRefreshOnAppear
|
||||
}
|
||||
|
||||
var filteredRuntime: [RuntimeLogEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return snapshot.runtime.filter { entry in
|
||||
(selectedSource == nil || entry.source == selectedSource)
|
||||
&& (selectedLevel == nil || entry.level == selectedLevel)
|
||||
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
|
||||
&& (query.isEmpty || matches(entry.message, query)
|
||||
|| matches(entry.code, query)
|
||||
|| matches(entry.eventType, query)
|
||||
|| matches(entry.instanceName, query))
|
||||
}
|
||||
}
|
||||
|
||||
var filteredConnections: [ConnectionLogEntry] {
|
||||
guard let snapshot else { return [] }
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return snapshot.connections.filter { entry in
|
||||
(selectedOutcome == nil || entry.outcome == selectedOutcome)
|
||||
&& (selectedInstanceID == nil || entry.instanceID == selectedInstanceID)
|
||||
&& (query.isEmpty || matches(entry.errorCode, query)
|
||||
|| matches(entry.instanceName, query))
|
||||
}
|
||||
}
|
||||
|
||||
var filteredDiagnostics: [InstanceDiagnostic] {
|
||||
guard let snapshot else { return [] }
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !query.isEmpty else { return snapshot.diagnostics }
|
||||
return snapshot.diagnostics.filter { diagnostic in
|
||||
matches(diagnostic.name, query)
|
||||
|| matches(diagnostic.origin, query)
|
||||
|| diagnostic.tags.contains { matches($0, query) }
|
||||
}
|
||||
}
|
||||
|
||||
var offlineCount: Int {
|
||||
snapshot?.diagnostics.filter { $0.state == .offline }.count ?? 0
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let loaded = try await repository.list(InstanceQuery())
|
||||
instances = loaded
|
||||
let fetched = try await service.refresh(loaded)
|
||||
let storedRuntime = try await store.runtimeLogs()
|
||||
let storedConnections = try await store.connectionLogs()
|
||||
var runtime = fetched.runtime
|
||||
for entry in storedRuntime where !runtime.contains(where: { $0.id == entry.id }) {
|
||||
runtime.append(entry)
|
||||
}
|
||||
runtime.sort { $0.occurredAt > $1.occurredAt }
|
||||
|
||||
var connections = fetched.connections
|
||||
for entry in storedConnections where !connections.contains(where: { $0.id == entry.id }) {
|
||||
connections.append(entry)
|
||||
}
|
||||
connections.sort { $0.observedAt > $1.observedAt }
|
||||
snapshot = LogSnapshot(
|
||||
observedAt: fetched.observedAt,
|
||||
runtime: Array(runtime.prefix(500)),
|
||||
counts: Self.recount(runtime),
|
||||
connections: Array(connections.prefix(200)),
|
||||
diagnostics: fetched.diagnostics
|
||||
)
|
||||
} catch {
|
||||
errorMessage = "无法加载日志中心。"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func pruneConnections(olderThanDays days: Int) async -> Int {
|
||||
let cutoff = Date().addingTimeInterval(TimeInterval(-days * 24 * 60 * 60))
|
||||
do {
|
||||
let removed = try await store.pruneConnections(before: cutoff)
|
||||
if removed > 0 {
|
||||
await refresh()
|
||||
}
|
||||
return removed
|
||||
} catch {
|
||||
errorMessage = "清理连接日志失败。"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func matches(_ value: String?, _ query: String) -> Bool {
|
||||
value?.localizedLowercase.contains(query) == true
|
||||
}
|
||||
|
||||
private static func recount(_ runtime: [RuntimeLogEntry]) -> [RuntimeLogSource: Int] {
|
||||
var counts: [RuntimeLogSource: Int] = [:]
|
||||
for source in RuntimeLogSource.allCases {
|
||||
counts[source] = runtime.filter { $0.source == source }.count
|
||||
}
|
||||
return counts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Foundation
|
||||
|
||||
public enum SMSDirection: String, Equatable, Sendable {
|
||||
case incoming
|
||||
case outgoing
|
||||
case unknown
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .incoming: return "收件"
|
||||
case .outgoing: return "发件"
|
||||
case .unknown: return "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct SMSMessage: Equatable, Identifiable, Sendable {
|
||||
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
|
||||
public let upstreamID: String
|
||||
public let instanceID: UUID
|
||||
public let instanceName: String
|
||||
public let phoneNumber: String
|
||||
public let content: String
|
||||
public let direction: SMSDirection
|
||||
public let status: String
|
||||
public let timestamp: String
|
||||
public var id: String { "\(instanceID.uuidString)#\(upstreamID)" }
|
||||
|
||||
/// 上游 status 是英文原词,列表里直接展示会混进一行英文;未知值仍然原样显示,不做假结论。
|
||||
public var statusLabel: String? {
|
||||
let key = status.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
guard key.isEmpty == false else { return nil }
|
||||
switch key {
|
||||
case "received", "in", "incoming": return "已接收"
|
||||
case "sent": return "已发送"
|
||||
case "delivered": return "已送达"
|
||||
case "read": return "已读"
|
||||
case "failed", "error": return "发送失败"
|
||||
case "pending", "queued", "sending": return "发送中"
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
/// 收件的"已接收"和方向标签说的是同一件事,只有发件状态才值得再占一个位置。
|
||||
public var deliveryLabel: String? {
|
||||
guard direction == .outgoing else { return nil }
|
||||
return statusLabel
|
||||
}
|
||||
|
||||
public init(
|
||||
upstreamID: String,
|
||||
instanceID: UUID,
|
||||
instanceName: String,
|
||||
phoneNumber: String,
|
||||
content: String,
|
||||
direction: SMSDirection,
|
||||
status: String,
|
||||
timestamp: String
|
||||
) {
|
||||
self.upstreamID = upstreamID
|
||||
self.instanceID = instanceID
|
||||
self.instanceName = instanceName
|
||||
self.phoneNumber = phoneNumber
|
||||
self.content = content
|
||||
self.direction = direction
|
||||
self.status = status
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
public struct MessagePage: Equatable, Sendable {
|
||||
public let messages: [SMSMessage]
|
||||
public let total: Int
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import Foundation
|
||||
|
||||
public enum MessageServiceError: Error, Equatable {
|
||||
case invalidInput
|
||||
case invalidResponse
|
||||
case unauthorized
|
||||
}
|
||||
|
||||
public struct MessageService: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
private let sessions: InstanceSessionStore
|
||||
private let credentials: (any CredentialStore)?
|
||||
|
||||
public init(
|
||||
client: any SimAdminClientProtocol,
|
||||
sessions: InstanceSessionStore,
|
||||
credentials: (any CredentialStore)? = nil
|
||||
) {
|
||||
self.client = client
|
||||
self.sessions = sessions
|
||||
self.credentials = credentials
|
||||
}
|
||||
|
||||
public func messages(
|
||||
_ instances: [Instance],
|
||||
limit: Int = 50,
|
||||
direction: SMSDirection? = nil
|
||||
) async throws -> MessagePage {
|
||||
let directionText: String?
|
||||
switch direction {
|
||||
case .incoming: directionText = "incoming"
|
||||
case .outgoing: directionText = "outgoing"
|
||||
default: directionText = nil
|
||||
}
|
||||
|
||||
var collected: [SMSMessage] = []
|
||||
var total = 0
|
||||
var lastError: Error?
|
||||
for instance in instances {
|
||||
do {
|
||||
let response = try await request(.smsList(limit: limit, offset: 0, direction: directionText), instance)
|
||||
let page = try Self.parsePage(response, instance)
|
||||
collected.append(contentsOf: page.messages)
|
||||
total += page.total
|
||||
} catch {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
if collected.isEmpty && total == 0, let lastError {
|
||||
throw lastError
|
||||
}
|
||||
collected.sort { $0.timestamp > $1.timestamp }
|
||||
return MessagePage(messages: collected, total: total)
|
||||
}
|
||||
|
||||
public func send(
|
||||
_ instance: Instance,
|
||||
phoneNumber rawPhoneNumber: String,
|
||||
content rawContent: String
|
||||
) async throws {
|
||||
let phoneNumber = rawPhoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let content = rawContent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !phoneNumber.isEmpty, !content.isEmpty else {
|
||||
throw MessageServiceError.invalidInput
|
||||
}
|
||||
_ = try await request(.smsSend(phoneNumber: phoneNumber, content: content), instance)
|
||||
}
|
||||
|
||||
public func delete(_ ids: [String], from instance: Instance) async throws {
|
||||
let numericIDs = ids.compactMap(Int.init)
|
||||
guard !numericIDs.isEmpty else {
|
||||
throw MessageServiceError.invalidInput
|
||||
}
|
||||
_ = try await request(.smsBatchDelete(ids: numericIDs), instance)
|
||||
}
|
||||
|
||||
private func request(_ route: UpstreamRoute, _ instance: Instance) async throws -> UpstreamResponse {
|
||||
var cookie = await sessions.cookie(for: instance.id, origin: instance.origin)
|
||||
do {
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
} catch UpstreamError.unauthorized, UpstreamError.forbidden {
|
||||
cookie = try await login(instance)
|
||||
return try await client.send(route, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
}
|
||||
|
||||
private func login(_ instance: Instance) async throws -> String {
|
||||
guard let credentials,
|
||||
let password = try credentials.get(instanceID: instance.id),
|
||||
!password.isEmpty
|
||||
else {
|
||||
throw MessageServiceError.unauthorized
|
||||
}
|
||||
let sessions = InstanceSessionService(store: sessions, client: client)
|
||||
let result = try await sessions.login(instance, password: password)
|
||||
guard result.authenticated,
|
||||
let cookie = await sessions.cookie(for: instance)
|
||||
else {
|
||||
throw MessageServiceError.unauthorized
|
||||
}
|
||||
return cookie
|
||||
}
|
||||
|
||||
static func parsePage(_ response: UpstreamResponse, _ instance: Instance) throws -> MessagePage {
|
||||
let root = try JSONSerialization.jsonObject(with: response.data)
|
||||
guard let container = Self.dataContainer(root),
|
||||
let data = container["data"] as? [String: Any] ?? container["response"] as? [String: Any]
|
||||
else {
|
||||
throw MessageServiceError.invalidResponse
|
||||
}
|
||||
let rawMessages = (data["messages"] as? [Any]) ?? []
|
||||
let messages = rawMessages.compactMap { Self.message($0, instance) }
|
||||
let total = (data["total"] as? Int) ?? messages.count
|
||||
return MessagePage(messages: messages, total: total)
|
||||
}
|
||||
|
||||
private static func dataContainer(_ root: Any) -> [String: Any]? {
|
||||
root as? [String: Any]
|
||||
}
|
||||
|
||||
private static func message(_ raw: Any, _ instance: Instance) -> SMSMessage? {
|
||||
guard let value = raw as? [String: Any] else { return nil }
|
||||
let id: String
|
||||
if let number = value["id"] as? Int {
|
||||
id = String(number)
|
||||
} else if let text = value["id"] as? String {
|
||||
id = text
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let directionText = (value["direction"] as? String)?.lowercased() ?? ""
|
||||
let direction: SMSDirection = directionText == "incoming" ? .incoming
|
||||
: directionText == "outgoing" ? .outgoing
|
||||
: .unknown
|
||||
return SMSMessage(
|
||||
upstreamID: id,
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
phoneNumber: value["phone_number"] as? String ?? "",
|
||||
content: value["content"] as? String ?? "",
|
||||
direction: direction,
|
||||
status: value["status"] as? String ?? "",
|
||||
timestamp: value["timestamp"] as? String ?? ""
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MessagesView: View {
|
||||
@StateObject private var model: MessagesViewModel
|
||||
@State private var pendingDeleteMessage: SMSMessage?
|
||||
|
||||
init(model: MessagesViewModel? = nil) {
|
||||
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
|
||||
// 所以调试开关必须先落到这个具体的 model 对象。
|
||||
let target = model ?? DefaultContainer.makeMessagesModel()
|
||||
#if DEBUG
|
||||
if DebugLaunch.has("--sheet-send") {
|
||||
target.showingSendForm = true
|
||||
}
|
||||
#endif
|
||||
_model = StateObject(wrappedValue: target)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
filterBar
|
||||
if model.isLoading && model.filteredRows.isEmpty {
|
||||
AILoadingCard("正在同步消息数据")
|
||||
} else if model.filteredRows.isEmpty {
|
||||
AICard {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIEmptyState(
|
||||
systemImage: "arrow.triangle.2.circlepath",
|
||||
title: "未能读取消息",
|
||||
message: errorMessage,
|
||||
actionTitle: "重试"
|
||||
) {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
} else {
|
||||
AIEmptyState(
|
||||
systemImage: "message",
|
||||
title: "暂无消息",
|
||||
message: "所有节点都没有返回短信。"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
ForEach(model.filteredRows) { message in
|
||||
messageCard(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("消息中心")
|
||||
.toolbar {
|
||||
// 发送入口和节点页的「添加实例」保持一致:放在导航栏右侧,
|
||||
// 底部固定条在 TabView 里会被自定义标签栏盖住,之前整条 CTA 是不可达的。
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
HStack(spacing: 8) {
|
||||
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
AIIconButton("发送消息", systemImage: "paperplane") {
|
||||
model.showingSendForm = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"删除这条消息?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDeleteMessage != nil },
|
||||
set: { if !$0 { pendingDeleteMessage = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("删除消息", role: .destructive) {
|
||||
guard let message = pendingDeleteMessage else { return }
|
||||
Task { await model.delete(message) }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
.sheet(isPresented: $model.showingSendForm) {
|
||||
SendMessageFormView(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
VStack(spacing: 8) {
|
||||
AISegmentedControl(
|
||||
"消息方向",
|
||||
selection: $model.direction,
|
||||
options: MessagesViewModel.DirectionFilter.allCases.map {
|
||||
(value: $0, title: $0.rawValue)
|
||||
}
|
||||
)
|
||||
|
||||
Picker("实例", selection: $model.selectedInstanceID) {
|
||||
Text("全部实例").tag(Optional<UUID>.none)
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface(compact: true)
|
||||
// 选择器右侧原本是一整片空白,这里补上当前条数,让这一行读完就知道结果规模。
|
||||
.overlay(alignment: .trailing) {
|
||||
Text("\(model.filteredRows.count) 条")
|
||||
.font(.caption.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.padding(.trailing, 12)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
AISearchField("搜索号码或内容", text: $model.searchText, minHeight: AITheme.compactControlHeight)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func messageCard(_ message: SMSMessage) -> some View {
|
||||
AICard(padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
AITag(
|
||||
message.direction.label,
|
||||
systemImage: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
|
||||
Spacer(minLength: 8)
|
||||
Text(TimestampText.short(message.timestamp))
|
||||
.font(.caption2.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(AITheme.faint)
|
||||
.lineLimit(1)
|
||||
.accessibilityLabel("时间 \(message.timestamp)")
|
||||
}
|
||||
Text(message.content)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineSpacing(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
// 删除按钮的点击区是 44pt,留在行内会把这一行撑到 44 高,正文与落款之间
|
||||
// 因此凭空多出一条约 22pt 的空带。这里让落款按自身高度排,点击区挂在
|
||||
// 行尾并垂直居中,多出的 12pt 落进卡片内边距,正文右缘留出等宽避让。
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.caption)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.imageScale(.small)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
if let delivery = message.deliveryLabel {
|
||||
Text(delivery)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 44)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.overlay(alignment: .trailing) {
|
||||
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger, quiet: true) {
|
||||
pendingDeleteMessage = message
|
||||
}
|
||||
// 图标在 44pt 点击区里居中,右移半个内边距让图标右缘与正文右缘对齐。
|
||||
.offset(x: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class MessagesViewModel: ObservableObject {
|
||||
enum DirectionFilter: String, CaseIterable, Identifiable {
|
||||
case all = "全部"
|
||||
case incoming = "收件"
|
||||
case outgoing = "发件"
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@Published var rows: [SMSMessage] = []
|
||||
@Published var instances: [Instance] = []
|
||||
@Published var selectedInstanceID: UUID?
|
||||
@Published var searchText = ""
|
||||
@Published var direction: DirectionFilter = .all
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var showingSendForm = false
|
||||
|
||||
private let repository: any InstanceRepository
|
||||
private let service: MessageService
|
||||
|
||||
init(repository: any InstanceRepository, service: MessageService) {
|
||||
self.repository = repository
|
||||
self.service = service
|
||||
}
|
||||
|
||||
var filteredRows: [SMSMessage] {
|
||||
rows.filter { message in
|
||||
selectedInstanceID == nil || message.instanceID == selectedInstanceID
|
||||
}.filter { message in
|
||||
switch direction {
|
||||
case .all: return true
|
||||
case .incoming: return message.direction == .incoming
|
||||
case .outgoing: return message.direction == .outgoing
|
||||
}
|
||||
}.filter { message in
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !query.isEmpty else { return true }
|
||||
return message.phoneNumber.localizedCaseInsensitiveContains(query)
|
||||
|| message.content.localizedCaseInsensitiveContains(query)
|
||||
|| message.instanceName.localizedCaseInsensitiveContains(query)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedInstance: Instance? {
|
||||
instances.first { $0.id == selectedInstanceID }
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let loaded = try await repository.list(InstanceQuery())
|
||||
instances = loaded
|
||||
if selectedInstanceID == nil || !loaded.contains(where: { $0.id == selectedInstanceID }) {
|
||||
selectedInstanceID = nil
|
||||
}
|
||||
let page = try await service.messages(loaded, limit: 100)
|
||||
rows = page.messages
|
||||
} catch MessageServiceError.unauthorized {
|
||||
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
|
||||
} catch {
|
||||
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func send(phoneNumber: String, content: String) async throws {
|
||||
guard let instance = selectedInstance ?? instances.first else {
|
||||
throw MessageServiceError.invalidInput
|
||||
}
|
||||
try await service.send(instance, phoneNumber: phoneNumber, content: content)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func delete(_ message: SMSMessage) async {
|
||||
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
|
||||
do {
|
||||
try await service.delete([message.id], from: instance)
|
||||
rows.removeAll { $0.id == message.id }
|
||||
} catch {
|
||||
errorMessage = "删除消息失败。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SendMessageFormView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: MessagesViewModel
|
||||
@State private var phoneNumber = ""
|
||||
@State private var content = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isSending = false
|
||||
|
||||
private var trimmedPhoneNumber: String {
|
||||
phoneNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var trimmedContent: String {
|
||||
content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
!model.instances.isEmpty && !trimmedPhoneNumber.isEmpty && !trimmedContent.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("实例")
|
||||
Picker(
|
||||
"实例",
|
||||
selection: Binding(
|
||||
get: { model.selectedInstanceID ?? model.instances.first?.id },
|
||||
set: { model.selectedInstanceID = $0 }
|
||||
)
|
||||
) {
|
||||
if model.instances.isEmpty {
|
||||
Text("暂无实例").tag(Optional<UUID>.none)
|
||||
}
|
||||
ForEach(model.instances) { instance in
|
||||
Text(instance.name).tag(Optional(instance.id))
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("收件号码")
|
||||
TextField("收件号码", text: $phoneNumber)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel("收件号码")
|
||||
.submitLabel(.next)
|
||||
#if os(iOS)
|
||||
.keyboardType(.phonePad)
|
||||
#endif
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
AIFieldLabel("消息内容")
|
||||
TextField("消息内容", text: $content, axis: .vertical)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel("消息内容")
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
if canSend { send() }
|
||||
}
|
||||
.lineLimit(4...8)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let validationMessage {
|
||||
AIStatusBanner(
|
||||
validationMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button("取消") { dismiss() }
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
|
||||
Button {
|
||||
send()
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if isSending {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(isSending ? "发送中" : "发送")
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(!canSend || isSending)
|
||||
}
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("发送消息")
|
||||
#if os(iOS)
|
||||
// 与「添加实例」一致:表行内标题,避免大标题在表单上方留出一整条空白。
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
private func send() {
|
||||
guard !isSending else { return }
|
||||
isSending = true
|
||||
Task {
|
||||
do {
|
||||
try await model.send(phoneNumber: trimmedPhoneNumber, content: trimmedContent)
|
||||
phoneNumber = ""
|
||||
content = ""
|
||||
validationMessage = nil
|
||||
dismiss()
|
||||
} catch {
|
||||
validationMessage = "发送失败,请检查号码、内容和实例登录状态。"
|
||||
}
|
||||
isSending = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@StateObject private var model: SettingsViewModel
|
||||
@ObservedObject private var themeStore = ThemeStore.shared
|
||||
@State private var showsClearDialog = false
|
||||
|
||||
init(model: SettingsViewModel? = nil) {
|
||||
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if let notice = model.notice {
|
||||
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
|
||||
}
|
||||
|
||||
themeCard
|
||||
runtimeCard
|
||||
storageCard
|
||||
securityCard
|
||||
dangerZoneCard
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("设置")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await model.refresh()
|
||||
}
|
||||
.confirmationDialog(
|
||||
"确认清除运行日志?此操作不可恢复。",
|
||||
isPresented: $showsClearDialog,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("清除运行日志", role: .destructive) {
|
||||
Task { await model.clearRuntimeLogs() }
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private var runtimeCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("应用信息", systemImage: "info.circle")
|
||||
if let runtime = model.runtime {
|
||||
AIKeyValueRow("版本", value: runtime.marketingVersion)
|
||||
// 构建号单独一行:同一营销版本会连续出多个包,只写版本号分不清装的是哪个。
|
||||
AIKeyValueRow("构建", value: runtime.buildVersion)
|
||||
AIKeyValueRow("系统", value: runtime.systemVersion)
|
||||
} else {
|
||||
Text("正在读取应用信息…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var themeCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
|
||||
AISectionTitle("主题外观", systemImage: "circle.lefthalf.filled")
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
ForEach(ThemeMode.allCases) { mode in
|
||||
themeOption(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func themeOption(_ mode: ThemeMode) -> some View {
|
||||
let isSelected = themeStore.mode == mode
|
||||
|
||||
return Button {
|
||||
themeStore.set(mode)
|
||||
} label: {
|
||||
VStack(spacing: 7) {
|
||||
Image(systemName: mode.systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
Text(mode.label)
|
||||
.font(.caption.weight(isSelected ? .semibold : .medium))
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 68)
|
||||
// 选中格直接反色成实心块,浅底黑、深底白;一层 10% 的色雾在三块同色瓷砖里根本分不出来。
|
||||
.background(isSelected ? AITheme.primary : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(isSelected ? Color.clear : AITheme.line)
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("主题:\(mode.label)")
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
||||
}
|
||||
|
||||
|
||||
private var storageCard: some View {
|
||||
AICard {
|
||||
NavigationLink {
|
||||
LogsView()
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
AISectionTitle("本地数据", systemImage: "externaldrive")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
AIKeyValueRow("日志记录", value: model.storageLabel)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("进入日志与诊断")
|
||||
}
|
||||
}
|
||||
|
||||
private var securityCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("隐私与数据", systemImage: "lock.shield")
|
||||
AIKeyValueRow("节点与凭据", value: "仅写入本机存储")
|
||||
AIKeyValueRow("网络请求", value: "只访问你添加的节点")
|
||||
// 卡内再套一个描边框会变成"卡中卡",这里退成一段脚注。
|
||||
HStack(alignment: .top, spacing: 7) {
|
||||
Image(systemName: "checkmark.seal")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AITheme.success)
|
||||
.padding(.top, 2)
|
||||
.accessibilityHidden(true)
|
||||
Text("不经过中转服务器,也不上传遥测数据;节点地址、密码与日志只保存在这台 iPhone 上,删除应用即彻底清除。")
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var dangerZoneCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
|
||||
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
|
||||
Button {
|
||||
showsClearDialog = true
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.accessibilityHidden(true)
|
||||
Text("清除运行日志")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.opacity(0.7)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundStyle(AITheme.danger)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: AITheme.compactControlHeight)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(AITheme.danger.opacity(0.07))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.danger.opacity(0.20))
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import Foundation
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct SettingsRuntime: Equatable {
|
||||
let appName: String
|
||||
let marketingVersion: String
|
||||
let buildVersion: String
|
||||
let platform: String
|
||||
let systemVersion: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SettingsViewModel: ObservableObject {
|
||||
@Published private(set) var runtime: SettingsRuntime?
|
||||
@Published private(set) var isLoading = false
|
||||
@Published var storageLabel = "—"
|
||||
@Published var notice: String?
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let store: any LogStore
|
||||
|
||||
init(store: any LogStore) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
runtime = SettingsRuntime(
|
||||
appName: AppMetadata.name,
|
||||
marketingVersion: AppMetadata.marketingVersion,
|
||||
buildVersion: AppMetadata.buildVersion,
|
||||
platform: "iOS",
|
||||
systemVersion: Self.systemVersion()
|
||||
)
|
||||
await loadStorageLabel()
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func clearRuntimeLogs() async -> Int {
|
||||
do {
|
||||
let removed = try await store.clearRuntimeLogs()
|
||||
notice = removed > 0 ? "已清除 \(removed) 条运行日志。" : "没有可清除的运行日志。"
|
||||
errorMessage = nil
|
||||
await loadStorageLabel()
|
||||
return removed
|
||||
} catch {
|
||||
notice = nil
|
||||
errorMessage = "清除运行日志失败。"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStorageLabel() async {
|
||||
do {
|
||||
let runtimeCount = try await store.runtimeLogs().count
|
||||
let connectionCount = try await store.connectionLogs().count
|
||||
storageLabel = "\(runtimeCount) 条运行日志 · \(connectionCount) 条连接日志"
|
||||
} catch {
|
||||
storageLabel = "—"
|
||||
errorMessage = "无法读取本地数据状态。"
|
||||
}
|
||||
}
|
||||
|
||||
/// 只报对用户有意义的系统版本,不暴露机器标识之类的原始信息。
|
||||
private static func systemVersion() -> String {
|
||||
#if canImport(UIKit)
|
||||
let device = UIDevice.current
|
||||
return "\(device.systemName) \(device.systemVersion)"
|
||||
#else
|
||||
let version = ProcessInfo.processInfo.operatingSystemVersion
|
||||
return "iOS \(version.majorVersion).\(version.minorVersion)"
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
public enum RepositoryError: Error, Equatable, Sendable {
|
||||
case invalidOrigin
|
||||
case duplicateOrigin
|
||||
case revisionConflict
|
||||
case notFound
|
||||
case databaseFailed
|
||||
}
|
||||
|
||||
public struct Instance: Codable, Equatable, Identifiable, Sendable {
|
||||
public let id: UUID
|
||||
public var name: String
|
||||
public var origin: URL
|
||||
public var tags: [String]
|
||||
public var credentialConfigured: Bool
|
||||
public var configRevision: Int
|
||||
public let createdAt: Date
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(
|
||||
id: UUID,
|
||||
name: String,
|
||||
origin: URL,
|
||||
tags: [String] = [],
|
||||
credentialConfigured: Bool = false,
|
||||
configRevision: Int = 1,
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.credentialConfigured = credentialConfigured
|
||||
self.configRevision = configRevision
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstanceInput: Sendable {
|
||||
public var name: String
|
||||
public var origin: URL
|
||||
public var tags: [String]
|
||||
public var password: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
origin: URL,
|
||||
tags: [String] = [],
|
||||
password: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstancePatch: Sendable {
|
||||
public var name: String?
|
||||
public var origin: URL?
|
||||
public var tags: [String]?
|
||||
public var password: String?
|
||||
|
||||
public init(
|
||||
name: String? = nil,
|
||||
origin: URL? = nil,
|
||||
tags: [String]? = nil,
|
||||
password: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.origin = origin
|
||||
self.tags = tags
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstanceQuery: Sendable {
|
||||
public var search: String?
|
||||
public var tags: [String]?
|
||||
|
||||
public init(search: String? = nil, tags: [String]? = nil) {
|
||||
self.search = search
|
||||
self.tags = tags
|
||||
}
|
||||
}
|
||||
|
||||
public protocol InstanceRepository: Sendable {
|
||||
func list(_ query: InstanceQuery) async throws -> [Instance]
|
||||
func get(_ id: UUID) async throws -> Instance?
|
||||
func create(_ input: InstanceInput) async throws -> Instance
|
||||
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
|
||||
func delete(_ id: UUID, expectedRevision: Int) async throws
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct MultiSimAdminApp: App {
|
||||
@StateObject private var themeStore = ThemeStore.shared
|
||||
@State private var tab: AppTab
|
||||
|
||||
init() {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
var initial: AppTab = .fleet
|
||||
if arguments.contains("--tab-messages") {
|
||||
initial = .messages
|
||||
} else if arguments.contains("--tab-settings") {
|
||||
initial = .settings
|
||||
}
|
||||
#if DEBUG
|
||||
if arguments.contains("--tab-detail") {
|
||||
initial = .debugDetail
|
||||
}
|
||||
if arguments.contains("--tab-logs") {
|
||||
initial = .debugLogs
|
||||
}
|
||||
#endif
|
||||
_tab = State(initialValue: initial)
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
Group {
|
||||
switch tab {
|
||||
case .fleet:
|
||||
NavigationStack { FleetView().aiInlineTitle() }
|
||||
case .messages:
|
||||
NavigationStack { MessagesView().aiInlineTitle() }
|
||||
case .settings:
|
||||
NavigationStack { SettingsView().aiInlineTitle() }
|
||||
#if DEBUG
|
||||
case .debugDetail:
|
||||
NavigationStack { DebugDetailView().aiInlineTitle() }
|
||||
case .debugLogs:
|
||||
NavigationStack { LogsView().aiInlineTitle() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
AITabBar(selection: $tab)
|
||||
}
|
||||
.tint(AITheme.primary)
|
||||
.preferredColorScheme(themeStore.mode.colorScheme)
|
||||
#if os(iOS)
|
||||
.toolbarBackground(.ultraThinMaterial, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension View {
|
||||
fileprivate func aiInlineTitle() -> some View {
|
||||
navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
#else
|
||||
extension View {
|
||||
fileprivate func aiInlineTitle() -> some View { self }
|
||||
}
|
||||
#endif
|
||||
|
||||
enum AppTab: Hashable {
|
||||
case fleet
|
||||
case messages
|
||||
case settings
|
||||
#if DEBUG
|
||||
case debugDetail
|
||||
case debugLogs
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// 本地截图验证用的入口,Release 构建不包含。
|
||||
struct DebugDetailView: View {
|
||||
@State private var target: Instance?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let instance = target {
|
||||
InstanceDetailView(instance: instance)
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json"))
|
||||
guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return }
|
||||
// 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。
|
||||
target = listed.first { $0.origin.port == 3000 } ?? listed[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct AITabBar: View {
|
||||
@Binding var selection: AppTab
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
tab(.fleet, "节点", "server.rack")
|
||||
tab(.messages, "消息", "message")
|
||||
tab(.settings, "设置", "gearshape")
|
||||
}
|
||||
.frame(height: 62)
|
||||
.background {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
Rectangle()
|
||||
.fill(AITheme.chrome)
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func tab(_ value: AppTab, _ title: String, _ systemImage: String) -> some View {
|
||||
let isSelected = selection == value
|
||||
return Button {
|
||||
selection = value
|
||||
} label: {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected] : [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Foundation
|
||||
|
||||
public struct FileInstanceRepository: InstanceRepository {
|
||||
private struct Store: Codable {
|
||||
var instances: [Instance] = []
|
||||
}
|
||||
|
||||
private let location: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository")
|
||||
|
||||
public init(location: URL) {
|
||||
self.location = location
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
self.encoder = encoder
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
public func list(_ query: InstanceQuery) async throws -> [Instance] {
|
||||
try await run { store in
|
||||
var instances = store.instances
|
||||
if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!search.isEmpty {
|
||||
instances = instances.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(search)
|
||||
|| $0.origin.absoluteString.localizedCaseInsensitiveContains(search)
|
||||
}
|
||||
}
|
||||
if let tags = query.tags, !tags.isEmpty {
|
||||
instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) }
|
||||
}
|
||||
return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
|
||||
public func get(_ id: UUID) async throws -> Instance? {
|
||||
try await run { $0.instances.first { $0.id == id } }
|
||||
}
|
||||
|
||||
public func create(_ input: InstanceInput) async throws -> Instance {
|
||||
try await run { store in
|
||||
guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw RepositoryError.invalidOrigin
|
||||
}
|
||||
let origin = try Self.normalizedOrigin(input.origin)
|
||||
guard !store.instances.contains(where: { $0.origin == origin }) else {
|
||||
throw RepositoryError.duplicateOrigin
|
||||
}
|
||||
let now = Date()
|
||||
let instance = Instance(
|
||||
id: UUID(),
|
||||
name: input.name.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
origin: origin,
|
||||
tags: Self.normalizedTags(input.tags),
|
||||
credentialConfigured: !(input.password ?? "").isEmpty,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
store.instances.append(instance)
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
|
||||
try await run { store in
|
||||
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
guard store.instances[index].configRevision == expectedRevision else {
|
||||
throw RepositoryError.revisionConflict
|
||||
}
|
||||
|
||||
var instance = store.instances[index]
|
||||
if let name = patch.name {
|
||||
let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin }
|
||||
instance.name = normalized
|
||||
}
|
||||
if let origin = patch.origin {
|
||||
let normalized = try Self.normalizedOrigin(origin)
|
||||
guard !store.instances.contains(where: { $0.id != id && $0.origin == normalized }) else {
|
||||
throw RepositoryError.duplicateOrigin
|
||||
}
|
||||
instance.origin = normalized
|
||||
}
|
||||
if let tags = patch.tags {
|
||||
instance.tags = Self.normalizedTags(tags)
|
||||
}
|
||||
if let password = patch.password {
|
||||
instance.credentialConfigured = !password.isEmpty
|
||||
}
|
||||
instance.updatedAt = Date()
|
||||
instance.configRevision += 1
|
||||
store.instances[index] = instance
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
public func delete(_ id: UUID, expectedRevision: Int) async throws {
|
||||
try await run { store in
|
||||
guard let index = store.instances.firstIndex(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
guard store.instances[index].configRevision == expectedRevision else {
|
||||
throw RepositoryError.revisionConflict
|
||||
}
|
||||
store.instances.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
private func run<T: Sendable>(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
var store = try self.readStore()
|
||||
let output = try transform(&store)
|
||||
try self.writeStore(store)
|
||||
continuation.resume(returning: output)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readStore() throws -> Store {
|
||||
guard FileManager.default.fileExists(atPath: location.path) else {
|
||||
return Store()
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(Store.self, from: Data(contentsOf: location))
|
||||
} catch {
|
||||
throw RepositoryError.databaseFailed
|
||||
}
|
||||
}
|
||||
|
||||
private func writeStore(_ store: Store) throws {
|
||||
do {
|
||||
let directory = location.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try encoder.encode(store).write(to: location, options: [.atomic])
|
||||
} catch {
|
||||
throw RepositoryError.databaseFailed
|
||||
}
|
||||
}
|
||||
|
||||
private static func normalizedOrigin(_ url: URL) throws -> URL {
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.scheme == "http" || components.scheme == "https",
|
||||
let host = components.host, !host.isEmpty
|
||||
else {
|
||||
throw RepositoryError.invalidOrigin
|
||||
}
|
||||
guard components.path.isEmpty || components.path == "/" else {
|
||||
throw RepositoryError.invalidOrigin
|
||||
}
|
||||
var normalized = components
|
||||
normalized.path = ""
|
||||
normalized.query = nil
|
||||
normalized.fragment = nil
|
||||
guard let result = normalized.url else {
|
||||
throw RepositoryError.invalidOrigin
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func normalizedTags(_ tags: [String]) -> [String] {
|
||||
var seen = Set<String>()
|
||||
return tags
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Foundation
|
||||
|
||||
public struct FileLogStore: LogStore {
|
||||
private struct Store: Codable {
|
||||
var runtime: [RuntimeLogEntry] = []
|
||||
var connections: [ConnectionLogEntry] = []
|
||||
}
|
||||
|
||||
private let location: URL
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.log-store")
|
||||
|
||||
public init(location: URL) {
|
||||
self.location = location
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
self.encoder = encoder
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
self.decoder = decoder
|
||||
}
|
||||
|
||||
public func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
||||
try await run { $0.runtime.sorted { $0.occurredAt > $1.occurredAt } }
|
||||
}
|
||||
|
||||
public func connectionLogs() async throws -> [ConnectionLogEntry] {
|
||||
try await run { $0.connections.sorted { $0.observedAt > $1.observedAt } }
|
||||
}
|
||||
|
||||
public func append(
|
||||
runtime: [RuntimeLogEntry],
|
||||
connections: [ConnectionLogEntry]
|
||||
) async throws {
|
||||
try await run { store in
|
||||
var mergedRuntime = store.runtime
|
||||
for entry in runtime where !mergedRuntime.contains(where: { $0.id == entry.id }) {
|
||||
mergedRuntime.append(entry)
|
||||
}
|
||||
mergedRuntime.sort { $0.occurredAt > $1.occurredAt }
|
||||
store.runtime = Array(mergedRuntime.prefix(2_000))
|
||||
|
||||
var mergedConnections = store.connections
|
||||
for entry in connections
|
||||
where !mergedConnections.contains(where: { $0.id == entry.id }) {
|
||||
mergedConnections.append(entry)
|
||||
}
|
||||
mergedConnections.sort { $0.observedAt > $1.observedAt }
|
||||
store.connections = Array(mergedConnections.prefix(20_000))
|
||||
}
|
||||
}
|
||||
|
||||
public func pruneConnections(before cutoff: Date) async throws -> Int {
|
||||
try await run { store in
|
||||
let retained = store.connections.filter { $0.observedAt >= cutoff }
|
||||
let removed = store.connections.count - retained.count
|
||||
store.connections = retained
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
public func clearRuntimeLogs() async throws -> Int {
|
||||
try await run { store in
|
||||
let removed = store.runtime.count
|
||||
store.runtime = []
|
||||
return removed
|
||||
}
|
||||
}
|
||||
|
||||
private func run<T: Sendable>(
|
||||
_ transform: @escaping @Sendable (inout Store) throws -> T
|
||||
) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
queue.async {
|
||||
do {
|
||||
var store = try self.readStore()
|
||||
let result = try transform(&store)
|
||||
try self.writeStore(store)
|
||||
continuation.resume(returning: result)
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readStore() throws -> Store {
|
||||
guard FileManager.default.fileExists(atPath: location.path) else {
|
||||
return Store()
|
||||
}
|
||||
do {
|
||||
return try decoder.decode(Store.self, from: Data(contentsOf: location))
|
||||
} catch {
|
||||
return Store()
|
||||
}
|
||||
}
|
||||
|
||||
private func writeStore(_ store: Store) throws {
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: location.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try encoder.encode(store).write(to: location, options: [.atomic])
|
||||
} catch {
|
||||
throw RepositoryError.databaseFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 970 KiB |
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key><string>zh_CN</string>
|
||||
<key>CFBundleDisplayName</key><string>MultiSimAdmin</string>
|
||||
<key>CFBundleExecutable</key><string>MultiSimAdmin</string>
|
||||
<key>CFBundleIdentifier</key><string>fun.chick.multi-simadmin</string>
|
||||
<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.2</string>
|
||||
<key>CFBundleVersion</key><string>10</string>
|
||||
<key>LSRequiresIPhoneOS</key><true/>
|
||||
<key>MinimumOSVersion</key><string>16.0</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key><true/>
|
||||
</dict>
|
||||
<key>NSPrincipalClass</key><string>UIApplication</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array><string>arm64</string></array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
|
||||
public protocol SimAdminClientProtocol: Sendable {
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse
|
||||
}
|
||||
|
||||
extension SimAdminClient: SimAdminClientProtocol {}
|
||||
|
||||
public enum ConnectionStatus: String, Equatable, Sendable {
|
||||
case online
|
||||
case offline
|
||||
case authRequired
|
||||
case degraded
|
||||
case unknown
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .online: return "在线"
|
||||
case .offline: return "离线"
|
||||
case .authRequired: return "需登录"
|
||||
case .degraded: return "降级"
|
||||
case .unknown: return "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionResult: Equatable, Sendable {
|
||||
public let status: ConnectionStatus
|
||||
public let requestLatencyMilliseconds: Int?
|
||||
public let checkedAt: Date
|
||||
|
||||
public init(
|
||||
status: ConnectionStatus,
|
||||
requestLatencyMilliseconds: Int? = nil,
|
||||
checkedAt: Date
|
||||
) {
|
||||
self.status = status
|
||||
self.requestLatencyMilliseconds = requestLatencyMilliseconds
|
||||
self.checkedAt = checkedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct ConnectionProbe: Sendable {
|
||||
private let client: any SimAdminClientProtocol
|
||||
|
||||
public init(client: any SimAdminClientProtocol) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
public func test(_ instance: Instance) async throws -> ConnectionResult {
|
||||
let startedAt = DispatchTime.now()
|
||||
do {
|
||||
let response = try await client.send(.health, baseURL: instance.origin, cookie: nil)
|
||||
let latency = Self.elapsedMilliseconds(since: startedAt)
|
||||
if response.status == 401 || response.status == 403 {
|
||||
return ConnectionResult(
|
||||
status: .authRequired,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
}
|
||||
return ConnectionResult(
|
||||
status: .online,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
} catch {
|
||||
let latency = Self.elapsedMilliseconds(since: startedAt)
|
||||
switch error {
|
||||
case UpstreamError.unauthorized:
|
||||
return ConnectionResult(
|
||||
status: .authRequired,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
case UpstreamError.timeout, is URLError:
|
||||
return ConnectionResult(
|
||||
status: .offline,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
default:
|
||||
return ConnectionResult(
|
||||
status: .degraded,
|
||||
requestLatencyMilliseconds: latency,
|
||||
checkedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func elapsedMilliseconds(since startedAt: DispatchTime) -> Int? {
|
||||
let nanoseconds = DispatchTime.now().uptimeNanoseconds &- startedAt.uptimeNanoseconds
|
||||
let milliseconds = Double(nanoseconds) / 1_000_000
|
||||
return milliseconds.isFinite ? Int(milliseconds.rounded()) : nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public protocol CredentialStore: Sendable {
|
||||
func get(instanceID: UUID) throws -> String?
|
||||
func set(instanceID: UUID, password: String) throws
|
||||
func remove(instanceID: UUID) throws
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
enum DefaultContainer {
|
||||
private static let credentials: any CredentialStore = FileCredentialStore(
|
||||
location: documentFileURL("credentials.json")
|
||||
)
|
||||
|
||||
private static let logStore: any LogStore = FileLogStore(
|
||||
location: documentFileURL("log-center.json")
|
||||
)
|
||||
|
||||
static func documentFileURL(_ name: String) -> URL {
|
||||
let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
return directory.appendingPathComponent(name)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeFleetModel() -> FleetViewModel {
|
||||
purgeLegacyGrouping()
|
||||
return FleetViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
probe: ConnectionProbe(client: SimAdminClient()),
|
||||
credentials: credentials,
|
||||
deviceService: DeviceService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 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(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
service: MessageService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeLogsModel(autoRefreshOnAppear: Bool = true) -> LogsViewModel {
|
||||
let store = logStore
|
||||
return LogsViewModel(
|
||||
repository: FileInstanceRepository(location: documentFileURL("instances.json")),
|
||||
service: LogCenterService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials,
|
||||
store: store
|
||||
),
|
||||
store: store,
|
||||
autoRefreshOnAppear: autoRefreshOnAppear
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeSettingsModel() -> SettingsViewModel {
|
||||
SettingsViewModel(store: logStore)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
|
||||
DeviceViewModel(
|
||||
instance: instance,
|
||||
service: DeviceService(
|
||||
client: SimAdminClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
credentials: credentials
|
||||
),
|
||||
probe: ConnectionProbe(client: SimAdminClient())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
public final class FileCredentialStore: @unchecked Sendable, CredentialStore {
|
||||
private struct FileModel: Codable {
|
||||
var credentials: [String: String] = [:]
|
||||
}
|
||||
|
||||
private let location: URL
|
||||
private let lock = NSLock()
|
||||
private var fileModel = FileModel()
|
||||
|
||||
public init(location: URL) {
|
||||
self.location = location
|
||||
lock.withLock {
|
||||
if let data = try? Data(contentsOf: location),
|
||||
let decoded = try? JSONDecoder().decode(FileModel.self, from: data) {
|
||||
fileModel = decoded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func get(instanceID: UUID) throws -> String? {
|
||||
lock.withLock {
|
||||
fileModel.credentials[instanceID.uuidString]
|
||||
}
|
||||
}
|
||||
|
||||
public func set(instanceID: UUID, password: String) throws {
|
||||
try lock.withLock {
|
||||
fileModel.credentials[instanceID.uuidString] = password
|
||||
try write()
|
||||
}
|
||||
}
|
||||
|
||||
public func remove(instanceID: UUID) throws {
|
||||
try lock.withLock {
|
||||
fileModel.credentials.removeValue(forKey: instanceID.uuidString)
|
||||
try write()
|
||||
}
|
||||
}
|
||||
|
||||
private func write() throws {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(fileModel)
|
||||
try data.write(to: location, options: [.atomic, .completeFileProtection])
|
||||
try FileManager.default.setAttributes(
|
||||
[.posixPermissions: 0o600],
|
||||
ofItemAtPath: location.path
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
public struct SessionMetadata: Equatable, Sendable {
|
||||
public let instanceID: UUID
|
||||
public let authenticated: Bool
|
||||
public let checkedAt: Date
|
||||
|
||||
public init(instanceID: UUID, authenticated: Bool, checkedAt: Date) {
|
||||
self.instanceID = instanceID
|
||||
self.authenticated = authenticated
|
||||
self.checkedAt = checkedAt
|
||||
}
|
||||
}
|
||||
|
||||
public struct InstanceSessionService: Sendable {
|
||||
private let store: InstanceSessionStore
|
||||
private let client: any SimAdminClientProtocol
|
||||
|
||||
public init(store: InstanceSessionStore, client: any SimAdminClientProtocol) {
|
||||
self.store = store
|
||||
self.client = client
|
||||
}
|
||||
|
||||
public func login(_ instance: Instance, password: String) async throws -> SessionMetadata {
|
||||
do {
|
||||
let response = try await client.send(
|
||||
.authLogin(password: password),
|
||||
baseURL: instance.origin,
|
||||
cookie: nil
|
||||
)
|
||||
if let cookie = Self.sessionCookie(response.setCookie) {
|
||||
await store.set(instance.id, origin: instance.origin, cookie: cookie)
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: true, checkedAt: Date())
|
||||
}
|
||||
} catch {
|
||||
// Rejected credentials fall through to the same explicit unauthenticated state.
|
||||
}
|
||||
await store.clear(instance.id)
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
|
||||
}
|
||||
|
||||
public func logout(_ instance: Instance) async throws -> SessionMetadata {
|
||||
if let cookie = await store.cookie(for: instance.id, origin: instance.origin) {
|
||||
_ = try? await client.send(.authLogout, baseURL: instance.origin, cookie: cookie)
|
||||
}
|
||||
await store.clear(instance.id)
|
||||
return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date())
|
||||
}
|
||||
|
||||
public func cookie(for instance: Instance) async -> String? {
|
||||
await store.cookie(for: instance.id, origin: instance.origin)
|
||||
}
|
||||
|
||||
static func sessionCookie(_ header: String?) -> String? {
|
||||
guard let header,
|
||||
let range = header.range(of: "simadmin_session=([^;\\s,]+)", options: .regularExpression)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(header[range])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// 仅 DEBUG 构建可用的启动开关,用于在模拟器里直接打开某个界面做视觉回归。
|
||||
#if DEBUG
|
||||
enum DebugLaunch {
|
||||
private static let args = ProcessInfo.processInfo.arguments
|
||||
|
||||
static func has(_ flag: String) -> Bool { args.contains(flag) }
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,4 @@
|
||||
public enum ErrorMessage {
|
||||
public static let network = "暂时无法连接实例,请稍后重试。"
|
||||
public static let unauthorized = "实例登录状态已失效,请重新登录。"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Foundation
|
||||
|
||||
/// 上游返回的时间戳格式并不统一(`yyyy-MM-dd HH:mm:ss`、ISO8601、秒/毫秒时间戳都出现过),
|
||||
/// 列表里需要一个稳定的短读法,解析不出来时原样回退,避免出现"--"这种假结论。
|
||||
public enum TimestampText {
|
||||
private static let parsers: [DateFormatter] = {
|
||||
let patterns = [
|
||||
"yyyy-MM-dd HH:mm:ss",
|
||||
"yyyy-MM-dd'T'HH:mm:ss",
|
||||
"yyyy-MM-dd HH:mm",
|
||||
"yyyy/MM/dd HH:mm:ss",
|
||||
"yyyy-MM-dd",
|
||||
]
|
||||
return patterns.map { pattern in
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.timeZone = .current
|
||||
formatter.dateFormat = pattern
|
||||
return formatter
|
||||
}
|
||||
}()
|
||||
|
||||
private static let utcParser: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let timeFormat: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let monthDayFormat: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "MM-dd HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private static let fullFormat: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
public static func date(from raw: String) -> Date? {
|
||||
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard text.isEmpty == false else { return nil }
|
||||
if let number = Double(text), text.allSatisfy({ $0.isNumber }) {
|
||||
// 13 位是毫秒,10 位是秒。
|
||||
return Date(timeIntervalSince1970: text.count > 11 ? number / 1000 : number)
|
||||
}
|
||||
let head = text.hasSuffix("Z") ? String(text.dropLast()) : text
|
||||
for parser in parsers {
|
||||
if let parsed = parser.date(from: head) {
|
||||
return text.hasSuffix("Z") ? utcParser.date(from: head) ?? parsed : parsed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 今天只看时分,今年看月日,跨年才写全年份。
|
||||
public static func short(_ raw: String, now: Date = Date()) -> String {
|
||||
guard let parsed = date(from: raw) else {
|
||||
return raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
let calendar = Calendar.current
|
||||
if calendar.isDate(parsed, inSameDayAs: now) {
|
||||
return timeFormat.string(from: parsed)
|
||||
}
|
||||
if calendar.isDate(parsed, equalTo: now, toGranularity: .year) {
|
||||
return monthDayFormat.string(from: parsed)
|
||||
}
|
||||
return fullFormat.string(from: parsed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct AIButtonStyle: ButtonStyle {
|
||||
public static let pressedScale: CGFloat = 0.97
|
||||
|
||||
public enum Kind: Hashable {
|
||||
case primary
|
||||
case secondary
|
||||
case danger
|
||||
|
||||
var foregroundColor: Color { foregroundColor(isEnabled: true) }
|
||||
|
||||
var backgroundColor: Color { backgroundColor(isEnabled: true) }
|
||||
|
||||
func foregroundColor(isEnabled: Bool) -> Color {
|
||||
switch self {
|
||||
case .primary: return isEnabled ? AITheme.onPrimary : AITheme.muted
|
||||
case .danger: return isEnabled ? AITheme.onDanger : AITheme.muted
|
||||
case .secondary: return isEnabled ? AITheme.ink : AITheme.muted
|
||||
}
|
||||
}
|
||||
|
||||
func backgroundColor(isEnabled: Bool) -> Color {
|
||||
if isEnabled { return kindBackground }
|
||||
// 禁用态:实心主色按钮保留主色淡版,其余退回中性灰。
|
||||
return self == .primary
|
||||
? AITheme.primary.opacity(AITheme.disabledFillOpacity)
|
||||
: AITheme.control
|
||||
}
|
||||
|
||||
private var kindBackground: Color {
|
||||
switch self {
|
||||
case .primary: return AITheme.primary
|
||||
case .secondary: return AITheme.control
|
||||
case .danger: return AITheme.danger
|
||||
}
|
||||
}
|
||||
|
||||
/// 禁用态下主按钮必须还能看出"这是主行动"。
|
||||
/// 之前三种 kind 禁用后都塌成同一块 control 灰,表单里"取消"和"保存"长得一模一样,
|
||||
/// 用户只能靠文字猜哪个是提交。这里让实心主色按钮改用同色相淡填充 + 描边,灰底次按钮不套。
|
||||
var usesTintedDisabledTreatment: Bool {
|
||||
self == .primary
|
||||
}
|
||||
}
|
||||
|
||||
public enum Size: Hashable {
|
||||
case compact
|
||||
case regular
|
||||
case large
|
||||
case icon
|
||||
case toolbar
|
||||
case toolbarInline
|
||||
}
|
||||
|
||||
private let kind: Kind
|
||||
private let size: Size
|
||||
private let fixedWidthRequest: CGFloat?
|
||||
let fillsWidth: Bool
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
public init(
|
||||
kind: Kind = .secondary,
|
||||
size: Size = .regular,
|
||||
fixedWidth: CGFloat? = nil,
|
||||
fillsWidth: Bool = false
|
||||
) {
|
||||
self.kind = kind
|
||||
self.size = size
|
||||
self.fixedWidthRequest = fixedWidth
|
||||
self.fillsWidth = fillsWidth
|
||||
}
|
||||
|
||||
public func makeBody(configuration: Configuration) -> some View {
|
||||
let height: CGFloat =
|
||||
switch size {
|
||||
case .compact: AITheme.compactButtonHeight
|
||||
case .large: AITheme.largeButtonHeight
|
||||
case .icon: AITheme.iconButtonSize
|
||||
case .regular: AITheme.buttonHeight
|
||||
case .toolbar: AITheme.toolbarButtonHeight
|
||||
case .toolbarInline: AITheme.toolbarButtonHeight
|
||||
}
|
||||
|
||||
let isTintedDisabled = kind.usesTintedDisabledTreatment && !isEnabled
|
||||
|
||||
configuration.label
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(
|
||||
.system(size: size == .compact || size == .toolbarInline ? 15 : 16, weight: .semibold)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(kind.foregroundColor(isEnabled: isEnabled))
|
||||
.padding(.horizontal, fixedWidth == nil ? (kind == .secondary ? 12 : 14) : 0)
|
||||
.frame(
|
||||
minWidth: (!fillsWidth && fixedWidth == nil) ? minimumWidth : 0,
|
||||
minHeight: height
|
||||
)
|
||||
.frame(maxWidth: fillsWidth ? .infinity : nil)
|
||||
.frame(width: fixedWidth)
|
||||
.background(
|
||||
// 主色面用纯色。之前的 primaryTop/primaryBottom 竖向渐变在系统里会被合成成灰紫,
|
||||
// 实测 #3B82F6 掉到 #6B81CA,整片按钮发脏。
|
||||
AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled))
|
||||
)
|
||||
.shadow(
|
||||
color: (kind == .secondary || isTintedDisabled) ? .clear : .black.opacity(0.10),
|
||||
radius: 12,
|
||||
x: 0,
|
||||
y: 5
|
||||
)
|
||||
.clipShape(
|
||||
RoundedRectangle(
|
||||
cornerRadius: cornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(
|
||||
cornerRadius: cornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.strokeBorder(
|
||||
isTintedDisabled
|
||||
? AITheme.primary.opacity(AITheme.disabledStrokeOpacity)
|
||||
: size == .toolbarInline && kind == .primary
|
||||
? AITheme.primary.opacity(0.28)
|
||||
: (kind == .secondary || !isEnabled ? AITheme.line : .clear)
|
||||
)
|
||||
}
|
||||
.scaleEffect(configuration.isPressed ? Self.pressedScale : 1)
|
||||
.opacity(configuration.isPressed ? 0.82 : 1)
|
||||
}
|
||||
|
||||
/// 工具栏里的图标按钮需要更小的圆角,否则 34pt 方块看起来像被切过。
|
||||
private var cornerRadius: CGFloat {
|
||||
switch size {
|
||||
case .toolbarInline: return AITheme.toolbarButtonHeight / 2
|
||||
case .icon: return AITheme.iconButtonRadius
|
||||
default: return AITheme.buttonRadius
|
||||
}
|
||||
}
|
||||
|
||||
private var minimumWidth: CGFloat {
|
||||
switch size {
|
||||
case .compact: return AITheme.compactButtonMinWidth
|
||||
case .regular: return AITheme.regularButtonMinWidth
|
||||
case .large: return 92
|
||||
case .icon: return AITheme.iconButtonSize
|
||||
case .toolbar, .toolbarInline: return AITheme.toolbarButtonMinWidth
|
||||
}
|
||||
}
|
||||
|
||||
private var fixedWidth: CGFloat? {
|
||||
fixedWidthRequest ?? (size == .icon ? AITheme.iconButtonSize : (size == .toolbar ? AITheme.toolbarButtonWidth : nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,924 @@
|
||||
import SwiftUI
|
||||
|
||||
public struct AICard<Content: View>: View {
|
||||
private let content: Content
|
||||
private let padding: CGFloat
|
||||
private let emphasis: Bool
|
||||
|
||||
public init(
|
||||
padding: CGFloat = 16,
|
||||
emphasis: Bool = false,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.padding = padding
|
||||
self.emphasis = emphasis
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(padding)
|
||||
.background(
|
||||
AICard.backgroundGradient(
|
||||
raisedTop: emphasis
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AICard {
|
||||
fileprivate static func backgroundGradient(raisedTop: Bool) -> LinearGradient {
|
||||
LinearGradient(
|
||||
colors: raisedTop
|
||||
? [AITheme.surfaceRaised, AITheme.surfaceRaised.opacity(0.94)]
|
||||
: [AITheme.surfaceRaised, AITheme.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AITag: View {
|
||||
private let text: String
|
||||
private let tint: Color?
|
||||
private let systemImage: String?
|
||||
|
||||
/// 不传 `tint` 时是中性归类标签(标签、方向这类不带健康含义的信息),
|
||||
/// 传了 `tint` 才是状态标签,用同色色雾与描边。颜色只留给后者。
|
||||
public init(_ text: String, tint: Color? = nil, systemImage: String? = nil) {
|
||||
self.text = text
|
||||
self.tint = tint
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let tint = self.tint
|
||||
HStack(spacing: 4) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(text)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
// 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见,
|
||||
// 所以描边权重必须够,不能让标签在暗色主题里消失。
|
||||
.frame(height: 28)
|
||||
.foregroundStyle(tint == nil ? AITheme.ink : tint!)
|
||||
.background(tint.map { $0.opacity(0.14) } ?? AITheme.tile)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(tint.map { $0.opacity(0.30) } ?? AITheme.tileLine)
|
||||
}
|
||||
.accessibilityLabel(text)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISectionTitle: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let detail: String?
|
||||
private let footer: Footer
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
detail: String? = nil,
|
||||
actionTitle: String? = nil,
|
||||
actionSystemImage: String? = nil,
|
||||
actionKind: AIButtonStyle.Kind = .secondary,
|
||||
action: (() -> Void)? = nil
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.detail = detail
|
||||
self.footer = Footer(
|
||||
title: actionTitle,
|
||||
systemImage: actionSystemImage,
|
||||
kind: actionKind,
|
||||
action: action
|
||||
)
|
||||
}
|
||||
|
||||
private struct Footer: View {
|
||||
let title: String?
|
||||
let systemImage: String?
|
||||
let kind: AIButtonStyle.Kind
|
||||
let action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
if let title, let action {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
if let systemImage {
|
||||
Label(title, systemImage: systemImage)
|
||||
} else {
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: kind, size: .compact))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 22, height: 22)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if let detail {
|
||||
Text(detail)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if Footer.self != EmptyView.self {
|
||||
footer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIFieldLabel: View {
|
||||
private let title: String
|
||||
|
||||
public init(_ title: String) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// 卡片内的二级面板:极浅底色 + 更浅的描边,只做"分组",不抢主信息的对比度。
|
||||
func aiInsetSurface(cornerRadius: CGFloat = 12) -> some View {
|
||||
self
|
||||
.background(AITheme.tile)
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.tileLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 带语义色的内嵌指标块:底色/描边都由 tint 决定,浅色下是一层很淡的色洗。
|
||||
struct AITintTile<Content: View>: View {
|
||||
private let tint: Color
|
||||
private let content: Content
|
||||
|
||||
init(tint: Color, @ViewBuilder content: () -> Content) {
|
||||
self.tint = tint
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(tint.opacity(0.09))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.tileRadius, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.20))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIStatCard: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let detail: String?
|
||||
private let tint: Color
|
||||
private let systemImage: String?
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
value: String,
|
||||
detail: String? = nil,
|
||||
systemImage: String? = nil,
|
||||
tint: Color
|
||||
) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.detail = detail
|
||||
self.systemImage = systemImage
|
||||
self.tint = tint
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
// 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 30, height: 30)
|
||||
.background(tint.opacity(0.12))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Text(value)
|
||||
.font(.headline.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInsetSurface(cornerRadius: AITheme.tileRadius)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIStatusBanner: View {
|
||||
private let message: String
|
||||
private let tint: Color
|
||||
private let systemImage: String
|
||||
|
||||
public init(_ message: String, tint: Color, systemImage: String) {
|
||||
self.message = message
|
||||
self.tint = tint
|
||||
self.systemImage = systemImage
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(tint)
|
||||
.accessibilityHidden(true)
|
||||
Text(message)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(tint)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(15)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius - 6, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.22))
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIEmptyState: View {
|
||||
private let systemImage: String
|
||||
private let title: String
|
||||
private let message: String
|
||||
private let actionTitle: String?
|
||||
private let actionSystemImage: String
|
||||
private let action: (() -> Void)?
|
||||
|
||||
public init(systemImage: String, title: String, message: String) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionTitle = nil
|
||||
self.actionSystemImage = "arrow.clockwise"
|
||||
self.action = nil
|
||||
}
|
||||
|
||||
/// 失败/空态带一个就地重试入口,避免"红色横幅 + 暂无数据"互相矛盾。
|
||||
public init(
|
||||
systemImage: String,
|
||||
title: String,
|
||||
message: String,
|
||||
actionTitle: String,
|
||||
actionSystemImage: String = "arrow.clockwise",
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.systemImage = systemImage
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionTitle = actionTitle
|
||||
self.actionSystemImage = actionSystemImage
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 27, weight: .medium))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
.frame(width: 54, height: 54)
|
||||
.background(AITheme.primary.opacity(0.10))
|
||||
.clipShape(Circle())
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.headline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if let actionTitle, let action {
|
||||
Button(action: action) {
|
||||
Label(actionTitle, systemImage: actionSystemImage)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .compact))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIListCard<Footer: View>: View {
|
||||
private let title: String
|
||||
private let subtitle: String?
|
||||
private let status: String?
|
||||
private let statusTint: Color?
|
||||
private let details: [String]
|
||||
private let footer: Footer
|
||||
|
||||
public init(
|
||||
title: String,
|
||||
subtitle: String? = nil,
|
||||
status: String? = nil,
|
||||
statusTint: Color? = nil,
|
||||
details: [String] = [],
|
||||
@ViewBuilder footer: () -> Footer = { EmptyView() }
|
||||
) {
|
||||
self.title = title
|
||||
self.subtitle = subtitle
|
||||
self.status = status
|
||||
self.statusTint = statusTint
|
||||
self.details = details
|
||||
self.footer = footer()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if let status {
|
||||
AITag(status, tint: statusTint ?? AITheme.muted)
|
||||
}
|
||||
}
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
if !details.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(details, id: \.self) { detail in
|
||||
Text(detail)
|
||||
.font(.caption)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if Footer.self != EmptyView.self {
|
||||
footer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AILoadingCard: View {
|
||||
private let message: String
|
||||
|
||||
public init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(AITheme.control)
|
||||
.clipShape(Circle())
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(16)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [AITheme.surfaceRaised, AITheme.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIActionRow<Content: View>: View {
|
||||
private let spacing: CGFloat
|
||||
private let content: Content
|
||||
|
||||
public init(
|
||||
spacing: CGFloat = 10,
|
||||
@ViewBuilder content: () -> Content
|
||||
) {
|
||||
self.spacing = spacing
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AIEqualWidthLayout(spacing: spacing) {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AIEqualWidthLayout: Layout {
|
||||
let spacing: CGFloat
|
||||
|
||||
func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) -> CGSize {
|
||||
guard !subviews.isEmpty else { return .zero }
|
||||
|
||||
let count = subviews.count
|
||||
let width: CGFloat
|
||||
if let proposedWidth = proposal.width {
|
||||
width = proposedWidth
|
||||
} else {
|
||||
let idealWidths = subviews.map { $0.sizeThatFits(.unspecified).width }
|
||||
width = idealWidths.reduce(0, +) + spacing * CGFloat(count - 1)
|
||||
}
|
||||
|
||||
let equalWidth = max(0, (width - spacing * CGFloat(count - 1)) / CGFloat(count))
|
||||
let height =
|
||||
subviews.map {
|
||||
$0.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil)).height
|
||||
}.max() ?? 0
|
||||
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) {
|
||||
guard !subviews.isEmpty else { return }
|
||||
|
||||
let equalWidth = max(
|
||||
0,
|
||||
(bounds.width - spacing * CGFloat(subviews.count - 1)) / CGFloat(subviews.count)
|
||||
)
|
||||
var x = bounds.minX
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(ProposedViewSize(width: equalWidth, height: nil))
|
||||
subview.place(
|
||||
at: CGPoint(x: x, y: bounds.midY - size.height / 2),
|
||||
proposal: ProposedViewSize(width: equalWidth, height: size.height)
|
||||
)
|
||||
x += equalWidth + spacing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIKeyValueRow: View {
|
||||
private let title: String
|
||||
private let value: String
|
||||
private let valueTint: Color
|
||||
private let monospaced: Bool
|
||||
|
||||
public init(_ title: String, value: String, tint: Color = AITheme.ink, monospaced: Bool = false) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.valueTint = tint
|
||||
self.monospaced = monospaced
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.lineLimit(2)
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(
|
||||
monospaced
|
||||
? Font.system(.subheadline, design: .monospaced, weight: .medium)
|
||||
: Font.subheadline.weight(.medium)
|
||||
)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(valueTint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.middle)
|
||||
.minimumScaleFactor(0.85)
|
||||
.textSelection(.enabled)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISegmentedControl<Value: Hashable>: View {
|
||||
private let title: String
|
||||
private let options: [(value: Value, title: String)]
|
||||
@Binding private var selection: Value
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
selection: Binding<Value>,
|
||||
options: [(value: Value, title: String)]
|
||||
) {
|
||||
self.title = title
|
||||
self._selection = selection
|
||||
self.options = options
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(options, id: \.value) { option in
|
||||
let isSelected = selection == option.value
|
||||
|
||||
Button {
|
||||
selection = option.value
|
||||
} label: {
|
||||
Text(option.title)
|
||||
.font(.subheadline.weight(isSelected ? .semibold : .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
|
||||
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
|
||||
// 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 11, style: .continuous)
|
||||
.fill(isSelected ? AITheme.segmentSelected : Color.clear)
|
||||
.shadow(color: isSelected ? AITheme.cardShadow : .clear, radius: 3, x: 0, y: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(option.title)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(AITheme.segmentTrack)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(AITheme.tileLine)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.16), value: selection)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIFieldStyle: TextFieldStyle {
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init() {}
|
||||
|
||||
public func _body(configuration: TextField<Self._Label>) -> some View {
|
||||
configuration
|
||||
.textFieldStyle(.plain)
|
||||
.focused($isFocused)
|
||||
.aiInputSurface()
|
||||
}
|
||||
}
|
||||
|
||||
public struct AISearchField: View {
|
||||
private let placeholder: String
|
||||
@Binding private var text: String
|
||||
private var onSubmit: (() -> Void)?
|
||||
private let minHeight: CGFloat
|
||||
@FocusState private var isFocused: Bool
|
||||
|
||||
public init(
|
||||
_ placeholder: String,
|
||||
text: Binding<String>,
|
||||
minHeight: CGFloat = AITheme.buttonHeight,
|
||||
onSubmit: (() -> Void)? = nil
|
||||
) {
|
||||
self.placeholder = placeholder
|
||||
self._text = text
|
||||
self.minHeight = minHeight
|
||||
self.onSubmit = onSubmit
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
TextField(placeholder, text: $text)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($isFocused)
|
||||
.submitLabel(.search)
|
||||
.onSubmit {
|
||||
onSubmit?()
|
||||
}
|
||||
if !text.isEmpty {
|
||||
Button {
|
||||
text = ""
|
||||
onSubmit?()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.aiInputSurface(minHeight: minHeight, isFocused: isFocused)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func aiInputSurface(
|
||||
minHeight: CGFloat = AITheme.inputHeight,
|
||||
isFocused: Bool = false
|
||||
) -> some View {
|
||||
self
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minHeight: minHeight, alignment: .leading)
|
||||
.background(isFocused ? AITheme.surfaceRaised : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)
|
||||
.strokeBorder(isFocused ? AITheme.primary.opacity(0.55) : AITheme.line)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.15), value: isFocused)
|
||||
}
|
||||
|
||||
func aiBottomActionBar<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
safeAreaInset(edge: .bottom, spacing: 0) {
|
||||
content()
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 10)
|
||||
.background {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
Rectangle()
|
||||
.fill(AITheme.surface.opacity(0.98))
|
||||
}
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
Rectangle()
|
||||
.fill(AITheme.line)
|
||||
.frame(height: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func aiSelectSurface(compact: Bool = false) -> some View {
|
||||
self
|
||||
.tint(AITheme.ink)
|
||||
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIToolbarButton: View {
|
||||
private let title: String
|
||||
private let systemImage: String?
|
||||
private let kind: AIButtonStyle.Kind
|
||||
private let action: () -> Void
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String? = nil,
|
||||
kind: AIButtonStyle.Kind = .secondary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.kind = kind
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
if let systemImage {
|
||||
Label(title, systemImage: systemImage)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
} else {
|
||||
Text(title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: kind, size: .toolbarInline))
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AIIconButton: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let kind: AIButtonStyle.Kind
|
||||
private let action: () -> Void
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
kind: AIButtonStyle.Kind = .secondary,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.kind = kind
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: kind, size: .icon))
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行内的轻量图标动作:同色淡底小方块,触区仍补足 44pt。
|
||||
/// 行内操作用实心药丸会把一行读成"按钮墙",这里让颜色只表达危险程度。
|
||||
public struct AISymbolAction: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let tint: Color
|
||||
/// 安静样式:只留着色图标与点击区,不再铺色块。
|
||||
/// 卡片列表里每行都放一块彩色方块会把整页染成"警告",删除这类低频动作更适合安静样式。
|
||||
private let isQuiet: Bool
|
||||
private let action: () -> Void
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
tint: Color = AITheme.muted,
|
||||
quiet: Bool = false,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.tint = tint
|
||||
self.isQuiet = quiet
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button {
|
||||
action()
|
||||
} label: {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize)
|
||||
.background(isQuiet ? Color.clear : tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous))
|
||||
.overlay {
|
||||
if isQuiet == false {
|
||||
RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.18))
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 按固有尺寸换行排布的流式布局:标签胶囊这类「宽度取决于文字」的控件用它。
|
||||
/// 栅格会把每枚胶囊拉到同一列宽,短标签右侧就空出一片,读起来像没加载完。
|
||||
public struct AIFlowLayout: Layout {
|
||||
public let spacing: CGFloat
|
||||
|
||||
public init(spacing: CGFloat = 8) {
|
||||
self.spacing = spacing
|
||||
}
|
||||
|
||||
public func sizeThatFits(
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
var widestRow: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > 0, x + size.width > maxWidth {
|
||||
widestRow = max(widestRow, x - spacing)
|
||||
y += rowHeight + spacing
|
||||
x = 0
|
||||
rowHeight = 0
|
||||
}
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
widestRow = max(widestRow, x - spacing)
|
||||
|
||||
return CGSize(width: max(0, widestRow), height: max(0, y + rowHeight))
|
||||
}
|
||||
|
||||
public func placeSubviews(
|
||||
in bounds: CGRect,
|
||||
proposal: ProposedViewSize,
|
||||
subviews: Subviews,
|
||||
cache: inout ()
|
||||
) {
|
||||
var x = bounds.minX
|
||||
var y = bounds.minY
|
||||
var rowHeight: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > bounds.minX, x + size.width > bounds.maxX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
|
||||
x += size.width + spacing
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AISkeletonLine: View {
|
||||
let width: CGFloat?
|
||||
let height: CGFloat
|
||||
|
||||
init(width: CGFloat? = nil, height: CGFloat) {
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
|
||||
init(width fraction: CGFloat, height: CGFloat) {
|
||||
self.init(width: nil, height: height)
|
||||
self.fraction = fraction
|
||||
}
|
||||
|
||||
private var fraction: CGFloat?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let fraction {
|
||||
GeometryReader { proxy in
|
||||
Capsule(style: .continuous)
|
||||
.frame(width: max(0, proxy.size.width * fraction))
|
||||
}
|
||||
} else if let width {
|
||||
Capsule(style: .continuous)
|
||||
.frame(width: width)
|
||||
}
|
||||
}
|
||||
.frame(height: height)
|
||||
.background(AISkeletonFill())
|
||||
}
|
||||
}
|
||||
|
||||
struct AISkeletonCard: View {
|
||||
let height: CGFloat
|
||||
|
||||
var body: some View {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.frame(height: height)
|
||||
.background(AISkeletonFill())
|
||||
}
|
||||
}
|
||||
|
||||
private struct AISkeletonFill: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(AITheme.control)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import SwiftUI
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#elseif canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
private enum AIPalette {
|
||||
#if canImport(UIKit)
|
||||
private static func dynamicColor(light: UIColor, dark: UIColor) -> Color {
|
||||
Color(
|
||||
UIColor { traits in
|
||||
traits.userInterfaceStyle == .dark ? dark : light
|
||||
})
|
||||
}
|
||||
|
||||
private static func rgba(
|
||||
_ red: CGFloat,
|
||||
_ green: CGFloat,
|
||||
_ blue: CGFloat,
|
||||
_ alpha: CGFloat
|
||||
) -> UIColor {
|
||||
UIColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha)
|
||||
}
|
||||
|
||||
private static func color(light: UIColor, dark: UIColor) -> Color {
|
||||
dynamicColor(light: light, dark: dark)
|
||||
}
|
||||
|
||||
// 中性石墨灰阶:所有中性色严格 R=G=B,既不冷到读出蓝,也不暖到读出米色;主色让位给
|
||||
// 黑/白,颜色只用来表达状态。原先 B 比 R 高 3~10 级,浅色下大面积文字与描边会蒙一层冷调。
|
||||
static let canvas = color(light: rgba(245, 245, 245, 1), dark: rgba(10, 10, 10, 1))
|
||||
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(21, 21, 21, 1))
|
||||
static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(29, 29, 29, 1))
|
||||
static let control = color(light: rgba(239, 239, 239, 1), dark: rgba(39, 39, 39, 1))
|
||||
static let tile = color(light: rgba(247, 247, 247, 1), dark: rgba(255, 255, 255, 0.055))
|
||||
static let tileLine = color(light: rgba(232, 232, 232, 1), dark: rgba(255, 255, 255, 0.09))
|
||||
static let chrome = color(light: rgba(252, 252, 252, 0.92), dark: rgba(13, 13, 13, 0.88))
|
||||
static let ink = color(light: rgba(18, 18, 18, 1), dark: rgba(242, 242, 242, 1))
|
||||
static let muted = color(light: rgba(92, 92, 92, 1), dark: rgba(164, 164, 164, 1))
|
||||
static let faint = color(light: rgba(130, 130, 130, 1), dark: rgba(118, 118, 118, 1))
|
||||
static let line = color(light: rgba(226, 226, 226, 1), dark: rgba(255, 255, 255, 0.11))
|
||||
static let brand = color(light: rgba(18, 18, 18, 1), dark: rgba(9, 9, 9, 1))
|
||||
static let primary = color(light: rgba(23, 23, 23, 1), dark: rgba(240, 240, 240, 1))
|
||||
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(13, 13, 13, 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(235, 235, 235, 1), dark: rgba(28, 28, 28, 1))
|
||||
static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(58, 58, 58, 1))
|
||||
static let cardShadow = color(light: rgba(18, 18, 18, 0.06), dark: rgba(0, 0, 0, 0.60))
|
||||
#elseif canImport(AppKit)
|
||||
static let canvas = Color(red: 0.961, green: 0.961, blue: 0.961)
|
||||
static let surface = Color.white
|
||||
static let control = Color(red: 0.937, green: 0.937, blue: 0.937)
|
||||
static let ink = Color(red: 0.071, green: 0.071, blue: 0.071)
|
||||
static let muted = Color(red: 0.361, green: 0.361, blue: 0.361)
|
||||
static let line = Color(red: 0.886, green: 0.886, blue: 0.886)
|
||||
static let brand = Color(red: 0.071, green: 0.071, blue: 0.071)
|
||||
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.090, green: 0.090, blue: 0.090)
|
||||
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.969, green: 0.969, blue: 0.969)
|
||||
static let tileLine = Color(red: 0.910, green: 0.910, blue: 0.910)
|
||||
static let faint = Color(red: 0.510, green: 0.510, blue: 0.510)
|
||||
static let segmentTrack = Color(red: 0.922, green: 0.922, blue: 0.922)
|
||||
static let segmentSelected = Color.white
|
||||
static let cardShadow = Color(red: 0.071, green: 0.071, blue: 0.071).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)
|
||||
}
|
||||
|
||||
static let canvas = color(245, 245, 245)
|
||||
static let surface = color(255, 255, 255)
|
||||
static let control = color(239, 239, 239)
|
||||
static let ink = color(18, 18, 18)
|
||||
static let muted = color(92, 92, 92)
|
||||
static let line = color(226, 226, 226)
|
||||
static let brand = color(18, 18, 18)
|
||||
static let surfaceRaised = color(255, 255, 255)
|
||||
static let chrome = color(255, 255, 255, 0.82)
|
||||
static let onPrimary = color(255, 255, 255)
|
||||
static let onDanger = color(255, 255, 255)
|
||||
static let primary = color(23, 23, 23)
|
||||
static let danger = color(190, 65, 57)
|
||||
static let warning = color(170, 111, 23)
|
||||
static let success = color(50, 150, 101)
|
||||
static let tile = color(247, 247, 247)
|
||||
static let tileLine = color(232, 232, 232)
|
||||
static let faint = color(130, 130, 130)
|
||||
static let segmentTrack = color(235, 235, 235)
|
||||
static let segmentSelected = color(255, 255, 255)
|
||||
static let cardShadow = color(18, 18, 18)
|
||||
#endif
|
||||
}
|
||||
|
||||
public enum AITheme {
|
||||
public static let canvas = AIPalette.canvas
|
||||
public static let surface = AIPalette.surface
|
||||
public static let surfaceRaised = AIPalette.surfaceRaised
|
||||
public static let control = AIPalette.control
|
||||
public static let tile = AIPalette.tile
|
||||
public static let tileLine = AIPalette.tileLine
|
||||
public static let chrome = AIPalette.chrome
|
||||
public static let ink = AIPalette.ink
|
||||
public static let muted = AIPalette.muted
|
||||
public static let faint = AIPalette.faint
|
||||
public static let line = AIPalette.line
|
||||
public static let brand = AIPalette.brand
|
||||
public static let primary = AIPalette.primary
|
||||
public static let onPrimary = AIPalette.onPrimary
|
||||
public static let danger = AIPalette.danger
|
||||
public static let onDanger = AIPalette.onDanger
|
||||
public static let warning = AIPalette.warning
|
||||
public static let success = AIPalette.success
|
||||
public static let segmentTrack = AIPalette.segmentTrack
|
||||
public static let segmentSelected = AIPalette.segmentSelected
|
||||
public static let cardShadow = AIPalette.cardShadow
|
||||
|
||||
public static let buttonHeight: CGFloat = 48
|
||||
// 停用态主按钮保留一层主色淡底,避免和次级按钮长得一模一样,层级读不出来。
|
||||
public static let disabledFillOpacity: Double = 0.14
|
||||
public static let disabledStrokeOpacity: Double = 0.22
|
||||
public static let compactButtonHeight: CGFloat = 44
|
||||
public static let largeButtonHeight: CGFloat = 56
|
||||
public static let toolbarButtonHeight: CGFloat = 44
|
||||
// 导航栏只给约 38pt 高度,44pt 的方块会被上下裁切,所以工具栏图标按钮单独取 34。
|
||||
public static let iconButtonSize: CGFloat = 34
|
||||
public static let iconButtonRadius: CGFloat = 11
|
||||
public static let buttonRadius: CGFloat = 16
|
||||
public static let cardRadius: CGFloat = 22
|
||||
public static let inputRadius: CGFloat = 16
|
||||
public static let inputHeight: CGFloat = 52
|
||||
public static let segmentedControlHeight: CGFloat = 44
|
||||
public static let tileRadius: CGFloat = 14
|
||||
public static let chipHeight: CGFloat = 34
|
||||
public static let compactControlHeight: CGFloat = 44
|
||||
public static let compactButtonMinWidth: CGFloat = 72
|
||||
public static let regularButtonMinWidth: CGFloat = 88
|
||||
public static let toolbarButtonMinWidth: CGFloat = 96
|
||||
public static let toolbarButtonWidth: CGFloat = 104
|
||||
public static let footerSecondaryButtonWidth: CGFloat = 104
|
||||
public static let listActionButtonWidth: CGFloat = 84
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
public enum ThemeMode: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
case system
|
||||
case light
|
||||
case dark
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var label: String {
|
||||
switch self {
|
||||
case .system: return "跟随系统"
|
||||
case .light: return "浅色"
|
||||
case .dark: return "深色"
|
||||
}
|
||||
}
|
||||
|
||||
public var systemImage: String {
|
||||
switch self {
|
||||
case .system: return "circle.lefthalf.filled"
|
||||
case .light: return "sun.max"
|
||||
case .dark: return "moon.stars"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ThemeStore: ObservableObject {
|
||||
static let shared = ThemeStore()
|
||||
|
||||
@Published private(set) var mode: ThemeMode
|
||||
private let defaults: UserDefaults
|
||||
private static let storageKey = "aiThemeMode"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
mode = ThemeMode(rawValue: defaults.string(forKey: Self.storageKey) ?? "") ?? .system
|
||||
}
|
||||
|
||||
func set(_ newMode: ThemeMode) {
|
||||
guard newMode != mode else { return }
|
||||
mode = newMode
|
||||
defaults.set(newMode.rawValue, forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
extension ThemeMode {
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .light: return .light
|
||||
case .dark: return .dark
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
public actor InstanceSessionStore {
|
||||
private var cookies: [UUID: (origin: URL, cookie: String)] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func cookie(for id: UUID, origin: URL) -> String? {
|
||||
guard let value = cookies[id], value.origin == origin else { return nil }
|
||||
return value.cookie
|
||||
}
|
||||
|
||||
public func set(_ id: UUID, origin: URL, cookie: String) {
|
||||
cookies[id] = (origin, cookie)
|
||||
}
|
||||
|
||||
public func clear(_ id: UUID) {
|
||||
cookies.removeValue(forKey: id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
public struct UpstreamResponse: Equatable, Sendable {
|
||||
public let status: Int
|
||||
public let data: Data
|
||||
public let setCookie: String?
|
||||
|
||||
public init(status: Int, data: Data, setCookie: String? = nil) {
|
||||
self.status = status
|
||||
self.data = data
|
||||
self.setCookie = setCookie
|
||||
}
|
||||
}
|
||||
|
||||
public struct SimAdminClient: Sendable {
|
||||
private let session: URLSession
|
||||
private let maximumBytes: Int
|
||||
|
||||
public init(session: URLSession = .shared, maximumBytes: Int = 262_144) {
|
||||
self.session = session
|
||||
self.maximumBytes = maximumBytes
|
||||
}
|
||||
|
||||
public func send(
|
||||
_ route: UpstreamRoute,
|
||||
baseURL: URL,
|
||||
cookie: String?
|
||||
) async throws -> UpstreamResponse {
|
||||
guard baseURL.scheme == "http" || baseURL.scheme == "https",
|
||||
baseURL.path.isEmpty || baseURL.path == "/"
|
||||
else {
|
||||
throw UpstreamError.invalidRequest
|
||||
}
|
||||
guard let url = URL(string: baseURL.absoluteString + route.path) else {
|
||||
throw UpstreamError.invalidRequest
|
||||
}
|
||||
var request = URLRequest(url: url, timeoutInterval: 30)
|
||||
request.httpMethod = route.method
|
||||
request.httpBody = route.body
|
||||
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
if route.body != nil {
|
||||
request.setValue("application/json", forHTTPHeaderField: "content-type")
|
||||
}
|
||||
if let cookie {
|
||||
request.setValue(cookie, forHTTPHeaderField: "cookie")
|
||||
}
|
||||
|
||||
do {
|
||||
let (data, response) = try await session.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, data.count <= maximumBytes else {
|
||||
throw UpstreamError.invalidResponse
|
||||
}
|
||||
switch http.statusCode {
|
||||
case 200...299:
|
||||
break
|
||||
case 401: throw UpstreamError.unauthorized
|
||||
case 403: throw UpstreamError.forbidden
|
||||
case 404, 405: throw UpstreamError.notSupported
|
||||
default: throw UpstreamError.transportFailed
|
||||
}
|
||||
let setCookie = http.allHeaderFields["Set-Cookie"] as? String
|
||||
return UpstreamResponse(status: http.statusCode, data: data, setCookie: setCookie)
|
||||
} catch let error as UpstreamError {
|
||||
throw error
|
||||
} catch let error as URLError where error.code == .timedOut {
|
||||
throw UpstreamError.timeout
|
||||
} catch {
|
||||
throw UpstreamError.transportFailed
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
public enum UpstreamRoute: Hashable, Sendable {
|
||||
case health
|
||||
case device
|
||||
case sim
|
||||
case network
|
||||
case signalStrength
|
||||
case stats
|
||||
case statsCpu
|
||||
case connectivity
|
||||
case smsList(limit: Int, offset: Int, direction: String?)
|
||||
case smsSend(phoneNumber: String, content: String)
|
||||
case smsBatchDelete(ids: [Int])
|
||||
case authLogin(password: String)
|
||||
case authLogout
|
||||
|
||||
public var method: String {
|
||||
switch self {
|
||||
case .health, .device, .sim, .network, .signalStrength, .stats, .statsCpu,
|
||||
.connectivity, .smsList:
|
||||
return "GET"
|
||||
case .smsSend, .smsBatchDelete, .authLogin, .authLogout:
|
||||
return "POST"
|
||||
}
|
||||
}
|
||||
|
||||
public var path: String {
|
||||
switch self {
|
||||
case .health: return "/api/health"
|
||||
case .device: return "/api/device"
|
||||
case .sim: return "/api/sim"
|
||||
case .network: return "/api/network"
|
||||
case .signalStrength: return "/api/network/signal-strength"
|
||||
case .stats: return "/api/stats"
|
||||
case .statsCpu: return "/api/stats/cpu"
|
||||
case .connectivity: return "/api/connectivity"
|
||||
case .smsList(let limit, let offset, let direction):
|
||||
var items = ["limit=\(limit)", "offset=\(offset)"]
|
||||
if let direction {
|
||||
items.append("direction=\(direction)")
|
||||
}
|
||||
return "/api/sms/list?\(items.joined(separator: "&"))"
|
||||
case .smsSend: return "/api/sms/send"
|
||||
case .smsBatchDelete: return "/api/sms/batch-delete"
|
||||
case .authLogin: return "/api/auth/login"
|
||||
case .authLogout: return "/api/auth/logout"
|
||||
}
|
||||
}
|
||||
|
||||
public var body: Data? {
|
||||
switch self {
|
||||
case .smsSend(let phoneNumber, let content):
|
||||
return try? JSONSerialization.data(
|
||||
withJSONObject: ["phone_number": phoneNumber, "content": content]
|
||||
)
|
||||
case .smsBatchDelete(let ids):
|
||||
return try? JSONSerialization.data(withJSONObject: ["ids": ids])
|
||||
case .authLogin(let password):
|
||||
return try? JSONSerialization.data(withJSONObject: ["password": password])
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum UpstreamError: Error, Equatable {
|
||||
case invalidRequest
|
||||
case unauthorized
|
||||
case forbidden
|
||||
case notSupported
|
||||
case timeout
|
||||
case transportFailed
|
||||
case invalidResponse
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class AIThemeTests: XCTestCase {
|
||||
func testControlDimensionsMatchIOS() {
|
||||
XCTAssertEqual(AITheme.buttonHeight, 48, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.compactButtonHeight, 44, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.largeButtonHeight, 56, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.iconButtonSize, 34, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.iconButtonRadius, 11, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.toolbarButtonMinWidth, 96, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.toolbarButtonWidth, 104, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.footerSecondaryButtonWidth, 104, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1)
|
||||
XCTAssertEqual(AIButtonStyle.pressedScale, 0.97, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testBottomActionsFillEqualWidths() {
|
||||
let primary = AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)
|
||||
let secondary = AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true)
|
||||
XCTAssertTrue(primary.fillsWidth)
|
||||
XCTAssertTrue(secondary.fillsWidth)
|
||||
}
|
||||
|
||||
func testFixedWidthDrawsInsideTheButtonStyle() {
|
||||
let button = AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
|
||||
XCTAssertTrue(button.fillsWidth == false)
|
||||
XCTAssertEqual(AITheme.listActionButtonWidth, 84, accuracy: 0.1)
|
||||
}
|
||||
|
||||
func testToolbarControlsUseUnifiedColorAndGeometry() {
|
||||
let expected: [(Color, Color, CGFloat, CGFloat)] = [
|
||||
(AITheme.control, AITheme.ink, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
|
||||
(AITheme.primary, AITheme.onPrimary, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
|
||||
(AITheme.danger, AITheme.onDanger, AITheme.toolbarButtonWidth, AITheme.toolbarButtonHeight),
|
||||
]
|
||||
|
||||
for (background, foreground, width, height) in expected {
|
||||
XCTAssertTrue(background == background)
|
||||
XCTAssertTrue(foreground == foreground)
|
||||
XCTAssertEqual(width, 104, accuracy: 0.1)
|
||||
XCTAssertEqual(height, 44, accuracy: 0.1)
|
||||
}
|
||||
}
|
||||
|
||||
func testButtonKindsHaveDistinctBaseColors() {
|
||||
let colors = [
|
||||
AIButtonStyle.Kind.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
].map(\.backgroundColor)
|
||||
XCTAssertEqual(Set(colors).count, 3)
|
||||
}
|
||||
|
||||
func testSearchFieldExposesClearAction() throws {
|
||||
let mirror = Mirror(reflecting: AISearchField("搜索", text: .constant("待清理")))
|
||||
let text = try XCTUnwrap(
|
||||
mirror.children.first { $0.label == "_text" }?.value as? Binding<String>)
|
||||
XCTAssertEqual(text.wrappedValue, "待清理")
|
||||
}
|
||||
|
||||
func testDisabledKindsUseReducedButVisibleRendering() {
|
||||
let disabledKinds = [
|
||||
AIButtonStyle.Kind.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
]
|
||||
let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) }
|
||||
|
||||
// 停用态仍然要保住按钮层级:主按钮留一层主色淡底,次级/危险退回中性 control。
|
||||
XCTAssertEqual(disabledColors.count, 3)
|
||||
XCTAssertEqual(disabledColors[1], AITheme.control)
|
||||
XCTAssertEqual(disabledColors[2], AITheme.control)
|
||||
XCTAssertTrue(disabledColors[0] != AITheme.control)
|
||||
// 只有实心主色按钮需要"同色相淡填充"处理,灰底次按钮不能套,否则两者又会长一样。
|
||||
XCTAssertEqual(
|
||||
[
|
||||
AIButtonStyle.Kind.primary,
|
||||
.secondary,
|
||||
.danger,
|
||||
].map(\.usesTintedDisabledTreatment),
|
||||
[true, false, false]
|
||||
)
|
||||
XCTAssertEqual(
|
||||
disabledKinds.map { $0.foregroundColor(isEnabled: false) },
|
||||
[
|
||||
AITheme.muted,
|
||||
AITheme.muted,
|
||||
AITheme.muted,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class AppMetadataTests: XCTestCase {
|
||||
func testMetadataUsesIPAFoundationContract() {
|
||||
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
|
||||
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
|
||||
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.2")
|
||||
XCTAssertEqual(AppMetadata.buildVersion, "10")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class ConnectionProbeTests: XCTestCase {
|
||||
func testProbeMapsUpstreamResults() async {
|
||||
let unauthorized = ConnectionProbe(client: MockClient(status: 401))
|
||||
let online = ConnectionProbe(client: MockClient(status: 200))
|
||||
let unavailable = ConnectionProbe(client: MockClient(status: 0))
|
||||
let instance = Self.instance()
|
||||
|
||||
let auth = try? await unauthorized.test(instance)
|
||||
let reachable = try? await online.test(instance)
|
||||
let offline = try? await unavailable.test(instance)
|
||||
|
||||
XCTAssertEqual(auth?.status, .authRequired)
|
||||
XCTAssertEqual(reachable?.status, .online)
|
||||
XCTAssertEqual(offline?.status, .offline)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Probe",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
credentialConfigured: false,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private struct MockClient: SimAdminClientProtocol {
|
||||
let status: Int
|
||||
|
||||
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
if status == 0 {
|
||||
throw UpstreamError.timeout
|
||||
}
|
||||
return UpstreamResponse(status: status, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class DeviceServiceTests: XCTestCase {
|
||||
func testLoadsAndFlattensWhitelistedReadSections() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
let snapshot = try await service.snapshot(instance)
|
||||
|
||||
XCTAssertEqual(client.routes, [.device, .sim, .network, .stats, .statsCpu, .connectivity])
|
||||
XCTAssertEqual(snapshot.sections[0].values["model"], "SIMBox 8")
|
||||
XCTAssertEqual(snapshot.sections[0].values["online"], "是")
|
||||
XCTAssertEqual(snapshot.sections[1].values["phone_numbers"], "13800138000")
|
||||
XCTAssertEqual(snapshot.sections[2].values["operator_name"], "中国移动")
|
||||
XCTAssertEqual(snapshot.sections[3].values["uptime_uptime_seconds"], "86400")
|
||||
XCTAssertEqual(snapshot.sections[4].values["core_count"], "4")
|
||||
XCTAssertEqual(snapshot.sections[5].values["state"], "connected")
|
||||
}
|
||||
|
||||
func testBuildsRunningSummaryFromUpstreamSections() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = DeviceTestClient()
|
||||
let service = DeviceService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
let summary = try await service.summary(instance)
|
||||
|
||||
XCTAssertEqual(summary.deviceName, "SIMBox 8")
|
||||
XCTAssertEqual(summary.phoneNumber, "13800138000")
|
||||
XCTAssertEqual(summary.operatorName, "中国移动")
|
||||
XCTAssertEqual(summary.connectivity, "connected")
|
||||
XCTAssertEqual(summary.firmwareVersion, "2.4.1")
|
||||
XCTAssertEqual(summary.totalStorageBytes, 1024)
|
||||
XCTAssertEqual(summary.availableStorageBytes, 512)
|
||||
XCTAssertEqual(summary.downloadRateBytesPerSecond, 128)
|
||||
XCTAssertEqual(summary.uploadRateBytesPerSecond, 64)
|
||||
XCTAssertEqual(summary.messagesToday, 12)
|
||||
XCTAssertEqual(summary.uptimeSeconds, 86400)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Device Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class DeviceTestClient: SimAdminClientProtocol, @unchecked Sendable {
|
||||
var routes: [UpstreamRoute] = []
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
routes.append(route)
|
||||
guard cookie == "simadmin_session=token" else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
let payload: [UpstreamRoute: String] = [
|
||||
.device: #"{"data":{"model":"SIMBox 8","online":true,"firmware_version":"2.4.1"}}"#,
|
||||
.sim: #"{"data":{"phone_numbers":["13800138000"],"iccid":"123"}}"#,
|
||||
.network: #"{"data":{"operator_name":"中国移动","registration_status":"registered"}}"#,
|
||||
.stats: #"{"data":{"uptime":{"uptime_seconds":86400},"disk_total_bytes":1024,"disk_available_bytes":512}}"#,
|
||||
.statsCpu: #"{"data":{"core_count":4,"model_name":"ARM CPU"}}"#,
|
||||
.connectivity: #"{"data":{"state":"connected","latency_ms":21,"download":128,"upload":64,"messages_today":12}}"#
|
||||
]
|
||||
guard let json = payload[route] else {
|
||||
throw UpstreamError.notSupported
|
||||
}
|
||||
return UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class ErrorMessageTests: XCTestCase {
|
||||
func testNetworkFailureUsesPlainActionableCopy() {
|
||||
XCTAssertEqual(ErrorMessage.network, "暂时无法连接实例,请稍后重试。")
|
||||
}
|
||||
|
||||
func testUnauthorizedFailureUsesLoginGuidance() {
|
||||
XCTAssertEqual(ErrorMessage.unauthorized, "实例登录状态已失效,请重新登录。")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class FileCredentialStoreTests: XCTestCase {
|
||||
func testSavesReadsAndRemovesPasswordInSandboxFile() throws {
|
||||
let location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("credential-store-\(UUID().uuidString).json")
|
||||
let store = FileCredentialStore(location: location)
|
||||
let instanceID = UUID()
|
||||
|
||||
XCTAssertNil(try store.get(instanceID: instanceID))
|
||||
|
||||
try store.set(instanceID: instanceID, password: "sandbox-secret")
|
||||
XCTAssertEqual(try store.get(instanceID: instanceID), "sandbox-secret")
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: location.path))
|
||||
|
||||
try store.remove(instanceID: instanceID)
|
||||
XCTAssertNil(try store.get(instanceID: instanceID))
|
||||
}
|
||||
|
||||
func testCredentialFileIsPrivate() throws {
|
||||
let location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("credential-store-\(UUID().uuidString).json")
|
||||
let store = FileCredentialStore(location: location)
|
||||
|
||||
try store.set(instanceID: UUID(), password: "private-secret")
|
||||
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
|
||||
XCTAssertEqual(attributes[.posixPermissions] as? Int, 0o600)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
@MainActor
|
||||
final class FleetViewModelTests: XCTestCase {
|
||||
private func transientPreferences() -> UserDefaults {
|
||||
let suite = "FleetViewModelTests.\(UUID().uuidString)"
|
||||
return UserDefaults(suiteName: suite)!
|
||||
}
|
||||
|
||||
func testRefreshSortsByNameAndReportsStatus() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: transientPreferences()
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Zulu",
|
||||
origin: URL(string: "http://192.168.1.30:3000")!,
|
||||
tags: ["lab"],
|
||||
password: nil
|
||||
))
|
||||
try await model.add(InstanceInput(
|
||||
name: "Alpha",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
|
||||
XCTAssertEqual(model.rows.map(\.status), [.online, .online])
|
||||
XCTAssertTrue(model.rows.allSatisfy { $0.latencyMilliseconds != nil })
|
||||
}
|
||||
|
||||
func testVisibleRowsFollowSearchAndTagFilters() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: transientPreferences()
|
||||
)
|
||||
try await model.add(InstanceInput(name: "Home", origin: URL(string: "http://192.168.1.70:3000")!, tags: ["home"]))
|
||||
try await model.add(InstanceInput(name: "Lab", origin: URL(string: "http://192.168.1.71:3000")!, tags: []))
|
||||
|
||||
await model.refresh()
|
||||
model.searchText = "home"
|
||||
|
||||
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
|
||||
|
||||
model.searchText = ""
|
||||
model.clearTagFilters()
|
||||
model.toggleTagFilter("Home")
|
||||
XCTAssertEqual(model.allTags, ["home"])
|
||||
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
|
||||
XCTAssertEqual(model.countForTagFilter("home"), 1)
|
||||
model.clearTagFilters()
|
||||
XCTAssertEqual(model.visibleRows.count, 2, "没有筛选时全部节点都要出现")
|
||||
}
|
||||
|
||||
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 {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: transientPreferences()
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Both",
|
||||
origin: URL(string: "http://192.168.1.90:3000")!,
|
||||
tags: ["primary", "field"]
|
||||
))
|
||||
try await model.add(InstanceInput(
|
||||
name: "Single",
|
||||
origin: URL(string: "http://192.168.1.91:3000")!,
|
||||
tags: ["primary"]
|
||||
))
|
||||
await model.refresh()
|
||||
|
||||
model.toggleTagFilter("primary")
|
||||
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both", "Single"])
|
||||
|
||||
model.toggleTagFilter("FIELD")
|
||||
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Both"])
|
||||
|
||||
model.toggleTagFilter("field")
|
||||
model.toggleTagFilter("primary")
|
||||
XCTAssertTrue(model.selectedTagFilters.isEmpty)
|
||||
|
||||
model.toggleTagFilter("vanished")
|
||||
XCTAssertTrue(model.isTagFilterActive("vanished"))
|
||||
await model.refresh()
|
||||
XCTAssertTrue(model.selectedTagFilters.isEmpty, "已不存在的标签筛选应当在刷新后自动清理")
|
||||
}
|
||||
|
||||
func testTagFilterSelectionSurvivesAcrossInstances() async throws {
|
||||
let suite = "FleetTagFilterTests.\(UUID().uuidString)"
|
||||
let repository = MemoryInstanceRepository()
|
||||
let first = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: UserDefaults(suiteName: suite)!
|
||||
)
|
||||
try await first.add(InstanceInput(
|
||||
name: "Relay",
|
||||
origin: URL(string: "http://192.168.1.92:3000")!,
|
||||
tags: ["relay"]
|
||||
))
|
||||
await first.refresh()
|
||||
first.toggleTagFilter("relay")
|
||||
|
||||
let second = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: UserDefaults(suiteName: suite)!
|
||||
)
|
||||
XCTAssertTrue(second.selectedTagFilters.contains("relay"))
|
||||
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
|
||||
}
|
||||
|
||||
func testUpdateRewritesNameAndTags() async throws {
|
||||
let model = FleetViewModel(
|
||||
repository: MemoryInstanceRepository(),
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: transientPreferences()
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Van",
|
||||
origin: URL(string: "http://192.168.1.80:3000")!,
|
||||
tags: ["old"]
|
||||
))
|
||||
await model.refresh()
|
||||
let row = try XCTUnwrap(model.rows.first)
|
||||
|
||||
try await model.update(row.id, InstanceInput(
|
||||
name: "Van Pro",
|
||||
origin: row.instance.origin,
|
||||
tags: ["field"]
|
||||
))
|
||||
|
||||
XCTAssertEqual(model.rows.first?.instance.name, "Van Pro")
|
||||
XCTAssertEqual(model.rows.first?.instance.tags, ["field"])
|
||||
}
|
||||
|
||||
func testApplyTagAddsAndRemovesAcrossTheWholeSelection() async throws {
|
||||
let model = FleetViewModel(
|
||||
repository: MemoryInstanceRepository(),
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: transientPreferences()
|
||||
)
|
||||
let first = try await model.add(InstanceInput(
|
||||
name: "First",
|
||||
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")!
|
||||
))
|
||||
await model.refresh()
|
||||
let ids = [first.id, second.id]
|
||||
|
||||
await model.applyTag("外场", to: ids)
|
||||
XCTAssertEqual(model.rows.map(\.instance.tags).map { $0.contains("外场") }, [true, true])
|
||||
XCTAssertEqual(model.tagCoverage("外场", in: ids), 2)
|
||||
|
||||
// 再点一次只撕掉这一个标签,其他标签必须留下。
|
||||
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 {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let credentials = NoopCredentialStore()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: credentials
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Secret",
|
||||
origin: URL(string: "http://192.168.1.40:3000")!,
|
||||
password: "pass"
|
||||
))
|
||||
|
||||
let row = try XCTUnwrap(model.rows.first)
|
||||
XCTAssertTrue(row.instance.credentialConfigured)
|
||||
XCTAssertEqual(credentials.saved[row.instance.id], "pass")
|
||||
|
||||
await model.delete([row.instance.id])
|
||||
XCTAssertTrue(model.rows.isEmpty)
|
||||
XCTAssertNil(credentials.saved[row.instance.id])
|
||||
}
|
||||
|
||||
func testDeleteSelectedClearsSelectionAndCredential() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let credentials = NoopCredentialStore()
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: credentials
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Selected",
|
||||
origin: URL(string: "http://192.168.1.50:3000")!,
|
||||
password: "pass"
|
||||
))
|
||||
try await model.add(InstanceInput(
|
||||
name: "Other",
|
||||
origin: URL(string: "http://192.168.1.60:3000")!,
|
||||
password: nil
|
||||
))
|
||||
let selectedID = model.rows[0].id
|
||||
model.selectionMode = true
|
||||
model.selectedIDs = [selectedID]
|
||||
|
||||
await model.deleteSelected()
|
||||
|
||||
XCTAssertEqual(model.rows.map(\.instance.name), ["Selected"])
|
||||
XCTAssertEqual(credentials.removed, [selectedID])
|
||||
XCTAssertFalse(model.selectionMode)
|
||||
XCTAssertTrue(model.selectedIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
struct AlwaysOnlineClient: SimAdminClientProtocol {
|
||||
func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
UpstreamResponse(status: 200, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
/// 指定主机一律不可达,用来验证"问题节点排在前面"。
|
||||
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] = [:]
|
||||
private var removedStorage: [UUID] = []
|
||||
|
||||
var saved: [UUID: String] {
|
||||
lock.withLock { savedStorage }
|
||||
}
|
||||
|
||||
var removed: [UUID] {
|
||||
lock.withLock { removedStorage }
|
||||
}
|
||||
|
||||
func get(instanceID: UUID) throws -> String? {
|
||||
saved[instanceID]
|
||||
}
|
||||
|
||||
func set(instanceID: UUID, password: String) throws {
|
||||
lock.withLock { savedStorage[instanceID] = password }
|
||||
}
|
||||
|
||||
func remove(instanceID: UUID) throws {
|
||||
lock.withLock {
|
||||
savedStorage[instanceID] = nil
|
||||
removedStorage.append(instanceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MemoryInstanceRepository: @unchecked Sendable, InstanceRepository {
|
||||
private let lock = NSLock()
|
||||
private var itemsStorage: [Instance] = []
|
||||
|
||||
private var items: [Instance] {
|
||||
get { lock.withLock { itemsStorage } }
|
||||
set { lock.withLock { itemsStorage = newValue } }
|
||||
}
|
||||
|
||||
func list(_: InstanceQuery) async throws -> [Instance] {
|
||||
items.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
func get(_ id: UUID) async throws -> Instance? {
|
||||
items.first { $0.id == id }
|
||||
}
|
||||
|
||||
func create(_ input: InstanceInput) async throws -> Instance {
|
||||
let now = Date()
|
||||
let instance = Instance(
|
||||
id: UUID(),
|
||||
name: input.name,
|
||||
origin: input.origin,
|
||||
tags: input.tags,
|
||||
credentialConfigured: !(input.password ?? "").isEmpty,
|
||||
configRevision: 1,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
)
|
||||
items.append(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else {
|
||||
throw RepositoryError.notFound
|
||||
}
|
||||
guard items[index].configRevision == expectedRevision else {
|
||||
throw RepositoryError.revisionConflict
|
||||
}
|
||||
var instance = items[index]
|
||||
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 let password = patch.password {
|
||||
instance.credentialConfigured = !password.isEmpty
|
||||
}
|
||||
instance.updatedAt = Date()
|
||||
instance.configRevision += 1
|
||||
items[index] = instance
|
||||
return instance
|
||||
}
|
||||
|
||||
func delete(_ id: UUID, expectedRevision: Int) async throws {
|
||||
items.removeAll { $0.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class InstanceRepositoryTests: XCTestCase {
|
||||
private var location: URL!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
location = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("multi-simadmin-\(UUID().uuidString).json")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
try? FileManager.default.removeItem(at: location)
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testCreateListUpdateAndDeleteInstance() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
let input = InstanceInput(
|
||||
name: "Lab Alpha",
|
||||
origin: URL(string: "http://192.168.3.55:3000/")!,
|
||||
tags: ["primary", "lab"],
|
||||
password: "secret"
|
||||
)
|
||||
|
||||
let created = try await repository.create(input)
|
||||
XCTAssertEqual(created.name, "Lab Alpha")
|
||||
XCTAssertEqual(created.origin.absoluteString, "http://192.168.3.55:3000")
|
||||
XCTAssertEqual(created.tags, ["primary", "lab"])
|
||||
XCTAssertEqual(created.configRevision, 1)
|
||||
XCTAssertTrue(created.credentialConfigured)
|
||||
|
||||
let patch = InstancePatch(name: "Alpha", tags: ["lab"])
|
||||
let updated = try await repository.update(created.id, expectedRevision: 1, patch)
|
||||
XCTAssertEqual(updated.name, "Alpha")
|
||||
XCTAssertEqual(updated.tags, ["lab"])
|
||||
XCTAssertEqual(updated.configRevision, 2)
|
||||
XCTAssertTrue(updated.credentialConfigured)
|
||||
|
||||
try await repository.delete(updated.id, expectedRevision: 2)
|
||||
let afterDelete = try await repository.list(InstanceQuery())
|
||||
XCTAssertTrue(afterDelete.isEmpty)
|
||||
}
|
||||
|
||||
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: [], password: nil)
|
||||
let first = try await repository.create(input)
|
||||
|
||||
do {
|
||||
let nested = InstanceInput(
|
||||
name: "Nested",
|
||||
origin: URL(string: "http://192.168.1.20:3000/admin")!,
|
||||
tags: [],
|
||||
password: nil
|
||||
)
|
||||
_ = try await repository.create(nested)
|
||||
XCTFail("Expected nested path rejection")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .invalidOrigin)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await repository.create(input)
|
||||
XCTFail("Expected duplicate-origin failure")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .duplicateOrigin)
|
||||
}
|
||||
|
||||
do {
|
||||
_ = try await repository.update(first.id, expectedRevision: 99, InstancePatch(name: "New"))
|
||||
XCTFail("Expected stale-revision failure")
|
||||
} catch let error as RepositoryError {
|
||||
XCTAssertEqual(error, .revisionConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func testListFiltersBySearchAndTag() async throws {
|
||||
let repository = FileInstanceRepository(location: location)
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Lab Alpha",
|
||||
origin: URL(string: "http://192.168.1.10:3000")!,
|
||||
tags: ["production"],
|
||||
password: nil
|
||||
))
|
||||
_ = try await repository.create(InstanceInput(
|
||||
name: "Home Beta",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
tags: ["lab"],
|
||||
password: nil
|
||||
))
|
||||
|
||||
let search = try await repository.list(InstanceQuery(search: "alpha"))
|
||||
XCTAssertEqual(search.map(\.name), ["Lab Alpha"])
|
||||
let tagged = try await repository.list(InstanceQuery(tags: ["lab"]))
|
||||
XCTAssertEqual(tagged.map(\.name), ["Home Beta"])
|
||||
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"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class InstanceSessionServiceTests: XCTestCase {
|
||||
func testLoginStoresAndClearsSessionCookie() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = MockSessionClient(status: 204)
|
||||
let service = InstanceSessionService(store: store, client: client)
|
||||
let instance = Self.instance()
|
||||
|
||||
let result = try await service.login(instance, password: "secret")
|
||||
XCTAssertTrue(result.authenticated)
|
||||
let cookie = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
XCTAssertEqual(cookie, "simadmin_session=token")
|
||||
|
||||
let rejected = try await service.login(instance, password: "bad")
|
||||
XCTAssertFalse(rejected.authenticated)
|
||||
let removed = await store.cookie(for: instance.id, origin: instance.origin)
|
||||
XCTAssertEqual(removed, nil)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Session",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private struct MockSessionClient: SimAdminClientProtocol {
|
||||
let status: Int
|
||||
|
||||
func send(
|
||||
_ route: UpstreamRoute,
|
||||
baseURL: URL,
|
||||
cookie: String?
|
||||
) async throws -> UpstreamResponse {
|
||||
guard case let .authLogin(submitted) = route else {
|
||||
return UpstreamResponse(status: 200, data: Data("{}.utf8".utf8), setCookie: nil)
|
||||
}
|
||||
let authorized = submitted == "secret"
|
||||
let setCookie = authorized ? "simadmin_session=token; Path=/" : nil
|
||||
return UpstreamResponse(status: authorized ? status : 200, data: Data("{}".utf8), setCookie: setCookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class LogCenterTests: XCTestCase {
|
||||
func testAggregatesDeliveryLogsConnectionsAndDiagnostics() async throws {
|
||||
let store = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: store
|
||||
)
|
||||
let instance = Self.instance()
|
||||
|
||||
let snapshot = try await service.refresh([instance])
|
||||
|
||||
XCTAssertEqual(snapshot.runtime.count, 0)
|
||||
XCTAssertEqual(snapshot.counts[.delivery], 0)
|
||||
XCTAssertEqual(store.runtimeStorage.count, 0)
|
||||
|
||||
XCTAssertEqual(snapshot.connections.count, 1)
|
||||
XCTAssertEqual(snapshot.connections.first?.outcome, .success)
|
||||
XCTAssertEqual(snapshot.connections.first?.state, "online")
|
||||
XCTAssertEqual(store.connectionStorage.count, 1)
|
||||
|
||||
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
||||
XCTAssertEqual(diagnostic.id, instance.id)
|
||||
XCTAssertEqual(diagnostic.state, .online)
|
||||
XCTAssertEqual(diagnostic.featureStatus.map(\.state), [
|
||||
.ready,
|
||||
.ready,
|
||||
.ready
|
||||
])
|
||||
XCTAssertEqual(diagnostic.connection?.total, 1)
|
||||
XCTAssertEqual(diagnostic.connection?.availabilityPercent, 100)
|
||||
}
|
||||
|
||||
func testRecordsUnauthorizedDeliveryLogsWithoutLosingConnectionHistory() async throws {
|
||||
let store = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(
|
||||
),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: store
|
||||
)
|
||||
let instance = Self.instance()
|
||||
|
||||
let snapshot = try await service.refresh([instance])
|
||||
|
||||
XCTAssertEqual(snapshot.runtime.count, 0)
|
||||
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
||||
XCTAssertEqual(diagnostic.state, .online)
|
||||
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .ready }.count, 3)
|
||||
XCTAssertEqual(store.runtimeStorage.count, 0)
|
||||
XCTAssertEqual(store.connectionStorage.count, 1)
|
||||
}
|
||||
|
||||
func testOfflineProbeBecomesFailedConnectionAndErrorLog() async throws {
|
||||
let store = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(connectionUnavailable: true),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: store
|
||||
)
|
||||
|
||||
let snapshot = try await service.refresh([Self.instance()])
|
||||
|
||||
XCTAssertEqual(snapshot.connections.first?.outcome, .failed)
|
||||
XCTAssertEqual(snapshot.connections.first?.errorCode, "TIMEOUT")
|
||||
XCTAssertTrue(snapshot.runtime.contains { $0.level == .error && $0.code == "TIMEOUT" })
|
||||
let diagnostic = try XCTUnwrap(snapshot.diagnostics.first)
|
||||
XCTAssertEqual(diagnostic.state, .offline)
|
||||
XCTAssertEqual(diagnostic.healthStatus, "超时")
|
||||
XCTAssertEqual(diagnostic.featureStatus.filter { $0.state == .unavailable }.count, 2)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testViewModelFiltersRuntimeLogsAndPrunesHistory() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let logStore = MemoryLogStore()
|
||||
let service = LogCenterService(
|
||||
client: LogCenterTestClient(),
|
||||
sessions: InstanceSessionStore(),
|
||||
store: logStore
|
||||
)
|
||||
let model = LogsViewModel(
|
||||
repository: repository,
|
||||
service: service,
|
||||
store: logStore
|
||||
)
|
||||
let instance = try await repository.create(InstanceInput(
|
||||
name: "Log Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
password: nil
|
||||
))
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertNotNil(model.snapshot)
|
||||
XCTAssertEqual(model.filteredRuntime.count, 0)
|
||||
XCTAssertEqual(model.filteredDiagnostics.count, 1)
|
||||
XCTAssertEqual(model.offlineCount, 0)
|
||||
|
||||
model.selectedInstanceID = instance.id
|
||||
model.searchText = "offline"
|
||||
model.selectedLevel = .error
|
||||
|
||||
XCTAssertEqual(model.filteredRuntime.count, 0)
|
||||
XCTAssertNil(model.filteredRuntime.first)
|
||||
|
||||
let removed = await model.pruneConnections(olderThanDays: 7)
|
||||
XCTAssertEqual(removed, 0)
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Log Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MemoryLogStore: LogStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var runtimeItems: [RuntimeLogEntry] = []
|
||||
private var connectionItems: [ConnectionLogEntry] = []
|
||||
|
||||
var runtimeStorage: [RuntimeLogEntry] {
|
||||
lock.withLock { runtimeItems }
|
||||
}
|
||||
|
||||
var connectionStorage: [ConnectionLogEntry] {
|
||||
lock.withLock { connectionItems }
|
||||
}
|
||||
|
||||
func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
||||
runtimeStorage
|
||||
}
|
||||
|
||||
func connectionLogs() async throws -> [ConnectionLogEntry] {
|
||||
connectionStorage
|
||||
}
|
||||
|
||||
func append(
|
||||
runtime: [RuntimeLogEntry],
|
||||
connections: [ConnectionLogEntry]
|
||||
) async throws {
|
||||
lock.withLock {
|
||||
var mergedRuntime = runtime
|
||||
for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) {
|
||||
mergedRuntime.append(existing)
|
||||
}
|
||||
runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt }
|
||||
|
||||
var mergedConnections = connections
|
||||
for existing in connectionItems
|
||||
where !connections.contains(where: { $0.id == existing.id }) {
|
||||
mergedConnections.append(existing)
|
||||
}
|
||||
connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt }
|
||||
}
|
||||
}
|
||||
|
||||
func pruneConnections(before _: Date) async throws -> Int {
|
||||
0
|
||||
}
|
||||
|
||||
func clearRuntimeLogs() async throws -> Int {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private struct LogCenterTestClient: SimAdminClientProtocol {
|
||||
var failingRoutes: Set<UpstreamRoute> = []
|
||||
var connectionUnavailable = false
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
if connectionUnavailable {
|
||||
throw UpstreamError.timeout
|
||||
}
|
||||
if failingRoutes.contains(route) {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
|
||||
switch route {
|
||||
case .health:
|
||||
return Self.response(#"{"status":"UP","version":"1.2.3","platform":"linux"}"#)
|
||||
case .device:
|
||||
return Self.response(
|
||||
#"{"status":"ok","data":{"model":"UFI-003","battery":82,"temperature":42}}"#
|
||||
)
|
||||
case .connectivity:
|
||||
return Self.response(#"{"status":"ok","data":{"connected":true}}"#)
|
||||
default:
|
||||
return UpstreamResponse(status: 404, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private static func response(_ json: String) -> UpstreamResponse {
|
||||
UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class MessageServiceTests: XCTestCase {
|
||||
func testParsesUpstreamEnvelopeAndNormalizesMessages() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = MessageTestClient()
|
||||
let service = MessageService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
let page = try await service.messages([instance], limit: 2)
|
||||
|
||||
XCTAssertEqual(client.routes.first, .smsList(limit: 2, offset: 0, direction: nil))
|
||||
XCTAssertEqual(page.messages.count, 1)
|
||||
let message = try XCTUnwrap(page.messages.first)
|
||||
XCTAssertEqual(message.upstreamID, "42")
|
||||
XCTAssertEqual(message.id, "\(instance.id.uuidString)#42")
|
||||
XCTAssertEqual(message.instanceID, instance.id)
|
||||
XCTAssertEqual(message.instanceName, "Message Node")
|
||||
XCTAssertEqual(message.phoneNumber, "13800138000")
|
||||
XCTAssertEqual(message.content, "device ready")
|
||||
XCTAssertEqual(message.direction, .outgoing)
|
||||
XCTAssertEqual(message.status, "sent")
|
||||
}
|
||||
|
||||
func testSendUsesStoredSessionAndRejectsInvalidInput() async throws {
|
||||
let store = InstanceSessionStore()
|
||||
let client = MessageTestClient()
|
||||
let service = MessageService(client: client, sessions: store)
|
||||
let instance = Self.instance()
|
||||
await store.set(instance.id, origin: instance.origin, cookie: "simadmin_session=token")
|
||||
|
||||
try await service.send(instance, phoneNumber: "13800138000", content: "hello")
|
||||
XCTAssertEqual(client.routes.last, .smsSend(phoneNumber: "13800138000", content: "hello"))
|
||||
|
||||
do {
|
||||
try await service.send(instance, phoneNumber: "", content: "hello")
|
||||
XCTFail("Empty phone number should fail.")
|
||||
} catch {
|
||||
XCTAssertEqual(error as? MessageServiceError, .invalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
private static func instance() -> Instance {
|
||||
Instance(
|
||||
id: UUID(),
|
||||
name: "Message Node",
|
||||
origin: URL(string: "http://192.168.1.20:3000")!,
|
||||
configRevision: 1,
|
||||
createdAt: Date(),
|
||||
updatedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class MessageTestClient: SimAdminClientProtocol, @unchecked Sendable {
|
||||
var routes: [UpstreamRoute] = []
|
||||
|
||||
func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
|
||||
routes.append(route)
|
||||
guard cookie == "simadmin_session=token" else {
|
||||
throw UpstreamError.unauthorized
|
||||
}
|
||||
switch route {
|
||||
case .smsList:
|
||||
let json = """
|
||||
{"status":"ok","data":{"total":1,"messages":[{"id":42,"phone_number":"13800138000",
|
||||
"content":"device ready","direction":"outgoing","status":"sent","timestamp":"2026-09-08T10:00:00Z"}]}}
|
||||
"""
|
||||
return UpstreamResponse(status: 200, data: Data(json.utf8))
|
||||
case .smsSend:
|
||||
return UpstreamResponse(status: 200, data: Data(#"{"status":"ok"}"#.utf8))
|
||||
default:
|
||||
return UpstreamResponse(status: 200, data: Data("{}".utf8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class SettingsViewModelTests: XCTestCase {
|
||||
@MainActor
|
||||
func testLoadsRuntimeWithoutServerCapabilities() async throws {
|
||||
let store = SettingsMemoryLogStore()
|
||||
let model = SettingsViewModel(store: store)
|
||||
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
|
||||
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.2")
|
||||
XCTAssertEqual(model.runtime?.buildVersion, "10")
|
||||
XCTAssertEqual(model.runtime?.platform, "iOS")
|
||||
XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false)
|
||||
|
||||
XCTAssertNil(model.errorMessage)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testClearsRuntimeLogsOnlyAndReportsRemovedCount() async throws {
|
||||
let store = SettingsMemoryLogStore()
|
||||
try await store.append(
|
||||
runtime: [Self.runtimeLog()],
|
||||
connections: [Self.connectionLog()]
|
||||
)
|
||||
let model = SettingsViewModel(store: store)
|
||||
|
||||
let removed = await model.clearRuntimeLogs()
|
||||
|
||||
XCTAssertEqual(removed, 1)
|
||||
XCTAssertTrue(store.runtimeStorage.isEmpty)
|
||||
XCTAssertEqual(store.connectionStorage.count, 1)
|
||||
XCTAssertEqual(model.notice, "已清除 1 条运行日志。")
|
||||
XCTAssertNil(model.errorMessage)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testClearFailureSetsErrorWithoutChangingStorage() async throws {
|
||||
let store = SettingsMemoryLogStore()
|
||||
try await store.append(
|
||||
runtime: [Self.runtimeLog()],
|
||||
connections: [Self.connectionLog()]
|
||||
)
|
||||
let failing = FailingLogStore(base: store)
|
||||
let model = SettingsViewModel(store: failing)
|
||||
|
||||
let removed = await model.clearRuntimeLogs()
|
||||
|
||||
XCTAssertEqual(removed, 0)
|
||||
XCTAssertEqual(store.runtimeStorage.count, 1)
|
||||
XCTAssertEqual(store.connectionStorage.count, 1)
|
||||
XCTAssertEqual(model.errorMessage, "清除运行日志失败。")
|
||||
XCTAssertNil(model.notice)
|
||||
}
|
||||
|
||||
private static func runtimeLog() -> RuntimeLogEntry {
|
||||
RuntimeLogEntry(
|
||||
id: "runtime-1",
|
||||
source: .delivery,
|
||||
level: .info,
|
||||
message: "测试日志",
|
||||
occurredAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func connectionLog() -> ConnectionLogEntry {
|
||||
ConnectionLogEntry(
|
||||
id: "connection-1",
|
||||
instanceID: UUID(),
|
||||
instanceName: "Node",
|
||||
outcome: .success,
|
||||
state: "online",
|
||||
durationMs: 20,
|
||||
observedAt: Date()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class SettingsMemoryLogStore: LogStore, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var runtimeItems: [RuntimeLogEntry] = []
|
||||
private var connectionItems: [ConnectionLogEntry] = []
|
||||
|
||||
var runtimeStorage: [RuntimeLogEntry] {
|
||||
lock.withLock { runtimeItems }
|
||||
}
|
||||
|
||||
var connectionStorage: [ConnectionLogEntry] {
|
||||
lock.withLock { connectionItems }
|
||||
}
|
||||
|
||||
func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
||||
runtimeStorage
|
||||
}
|
||||
|
||||
func connectionLogs() async throws -> [ConnectionLogEntry] {
|
||||
connectionStorage
|
||||
}
|
||||
|
||||
func append(
|
||||
runtime: [RuntimeLogEntry],
|
||||
connections: [ConnectionLogEntry]
|
||||
) async throws {
|
||||
lock.withLock {
|
||||
var mergedRuntime = runtime
|
||||
for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) {
|
||||
mergedRuntime.append(existing)
|
||||
}
|
||||
runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt }
|
||||
|
||||
var mergedConnections = connections
|
||||
for existing in connectionItems
|
||||
where !connections.contains(where: { $0.id == existing.id }) {
|
||||
mergedConnections.append(existing)
|
||||
}
|
||||
connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt }
|
||||
}
|
||||
}
|
||||
|
||||
func pruneConnections(before _: Date) async throws -> Int {
|
||||
0
|
||||
}
|
||||
|
||||
func clearRuntimeLogs() async throws -> Int {
|
||||
lock.withLock {
|
||||
let removed = runtimeItems.count
|
||||
runtimeItems = []
|
||||
return removed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct FailingLogStore: LogStore {
|
||||
let base: SettingsMemoryLogStore
|
||||
|
||||
func runtimeLogs() async throws -> [RuntimeLogEntry] {
|
||||
try await base.runtimeLogs()
|
||||
}
|
||||
|
||||
func connectionLogs() async throws -> [ConnectionLogEntry] {
|
||||
try await base.connectionLogs()
|
||||
}
|
||||
|
||||
func append(
|
||||
runtime: [RuntimeLogEntry],
|
||||
connections: [ConnectionLogEntry]
|
||||
) async throws {
|
||||
try await base.append(runtime: runtime, connections: connections)
|
||||
}
|
||||
|
||||
func pruneConnections(before cutoff: Date) async throws -> Int {
|
||||
try await base.pruneConnections(before: cutoff)
|
||||
}
|
||||
|
||||
func clearRuntimeLogs() async throws -> Int {
|
||||
throw RepositoryError.databaseFailed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class ThemeModeTests: XCTestCase {
|
||||
@MainActor
|
||||
func testLoadsPersistedThemeMode() throws {
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: "ThemeModeTests-\(UUID().uuidString)"))
|
||||
defaults.set(ThemeMode.dark.rawValue, forKey: "aiThemeMode")
|
||||
|
||||
XCTAssertEqual(ThemeStore(defaults: defaults).mode, .dark)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSelectionPersistsThemeMode() throws {
|
||||
let suiteName = "ThemeModeTests-\(UUID().uuidString)"
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
let store = ThemeStore(defaults: defaults)
|
||||
|
||||
store.set(.light)
|
||||
store.set(.system)
|
||||
|
||||
XCTAssertEqual(ThemeMode(rawValue: defaults.string(forKey: "aiThemeMode") ?? ""), .system)
|
||||
XCTAssertEqual(store.mode, .system)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class TimestampTextTests: XCTestCase {
|
||||
private let calendar = Calendar(identifier: .gregorian)
|
||||
|
||||
func testTodayOnlyShowsClockTime() {
|
||||
let output = TimestampText.short("2026-09-14 08:05:00", now: date(2026, 9, 14, 20, 0))
|
||||
XCTAssertEqual(output, "08:05")
|
||||
}
|
||||
|
||||
func testSameYearShowsMonthAndTime() {
|
||||
let output = TimestampText.short("2026-03-02 08:05:00", now: date(2026, 9, 14, 20, 0))
|
||||
XCTAssertEqual(output, "03-02 08:05")
|
||||
}
|
||||
|
||||
func testOtherYearFallsBackToDate() {
|
||||
let output = TimestampText.short("2025-12-31 23:59:00", now: date(2026, 9, 14, 20, 0))
|
||||
XCTAssertEqual(output, "2025-12-31")
|
||||
}
|
||||
|
||||
func testParsesEpochMilliseconds() {
|
||||
XCTAssertEqual(TimestampText.date(from: "1767484800000"), Date(timeIntervalSince1970: 1_767_484_800))
|
||||
}
|
||||
|
||||
func testParsesEpochSeconds() {
|
||||
XCTAssertEqual(TimestampText.date(from: "1767484800"), Date(timeIntervalSince1970: 1_767_484_800))
|
||||
}
|
||||
|
||||
func testZuluTimestampIsReadAsUTC() {
|
||||
let iso = try? XCTUnwrap(TimestampText.date(from: "2026-01-04T00:00:00Z"))
|
||||
XCTAssertEqual(iso, Date(timeIntervalSince1970: 1_767_484_800))
|
||||
}
|
||||
|
||||
func testUnparseableTextIsKeptAsIs() {
|
||||
XCTAssertEqual(TimestampText.short(" 刚刚 "), "刚刚")
|
||||
XCTAssertEqual(TimestampText.short(""), "")
|
||||
}
|
||||
|
||||
private func date(_ year: Int, _ month: Int, _ day: Int, _ hour: Int, _ minute: Int) -> Date {
|
||||
var components = DateComponents()
|
||||
components.year = year
|
||||
components.month = month
|
||||
components.day = day
|
||||
components.hour = hour
|
||||
components.minute = minute
|
||||
return calendar.date(from: components)!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import XCTest
|
||||
|
||||
@testable import MultiSimAdmin
|
||||
|
||||
final class UpstreamRouteTests: XCTestCase {
|
||||
func testKnownRoutesUseExactPathsMethodsAndPayloads() throws {
|
||||
XCTAssertEqual(UpstreamRoute.health.method, "GET")
|
||||
XCTAssertEqual(UpstreamRoute.health.path, "/api/health")
|
||||
XCTAssertEqual(UpstreamRoute.device.path, "/api/device")
|
||||
XCTAssertEqual(UpstreamRoute.sim.path, "/api/sim")
|
||||
XCTAssertEqual(UpstreamRoute.network.path, "/api/network")
|
||||
XCTAssertEqual(UpstreamRoute.signalStrength.path, "/api/network/signal-strength")
|
||||
XCTAssertEqual(UpstreamRoute.stats.path, "/api/stats")
|
||||
XCTAssertEqual(UpstreamRoute.statsCpu.path, "/api/stats/cpu")
|
||||
XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path,
|
||||
"/api/sms/list?limit=100&offset=0")
|
||||
XCTAssertEqual(UpstreamRoute.smsList(limit: 20, offset: 2, direction: "outgoing").path,
|
||||
"/api/sms/list?limit=20&offset=2&direction=outgoing")
|
||||
|
||||
let login = UpstreamRoute.authLogin(password: "secret")
|
||||
XCTAssertEqual(login.method, "POST")
|
||||
XCTAssertEqual(login.path, "/api/auth/login")
|
||||
let loginObject = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(login.body)) as? [String: String])
|
||||
XCTAssertEqual(loginObject, ["password": "secret"])
|
||||
|
||||
let delete = UpstreamRoute.smsBatchDelete(ids: [2, 1])
|
||||
XCTAssertEqual(delete.method, "POST")
|
||||
XCTAssertEqual(delete.path, "/api/sms/batch-delete")
|
||||
let deleteObject = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: XCTUnwrap(delete.body)) as? [String: [Int]]
|
||||
)
|
||||
XCTAssertEqual(deleteObject, ["ids": [2, 1]])
|
||||
}
|
||||
|
||||
func testSessionStoreIsIsolatedByInstanceAndOrigin() async {
|
||||
let store = InstanceSessionStore()
|
||||
let origin = URL(string: "http://192.168.1.10:3000")!
|
||||
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: origin, cookie: "a")
|
||||
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, origin: origin, cookie: "b")
|
||||
|
||||
let sameID = URL(string: "https://other.example")!
|
||||
await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: sameID, cookie: "c")
|
||||
await store.clear(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!)
|
||||
|
||||
let first = await store.cookie(
|
||||
for: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
|
||||
origin: origin
|
||||
)
|
||||
let second = await store.cookie(
|
||||
for: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!,
|
||||
origin: origin
|
||||
)
|
||||
XCTAssertEqual(first, nil)
|
||||
XCTAssertEqual(second, "b")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# iOS Native 0.2.0
|
||||
|
||||
MultiSimAdmin 0.2.0 是面向 iPhone 的原生 IPA 版本。应用直接连接用户添加的 SimAdmin 节点,不需要先启动 8788 控制台。本文是历史发布说明,现行说明见 [docs/ios-native.md](ios-native.md)。
|
||||
|
||||
## 本次变更
|
||||
|
||||
- 底部导航精简为节点、消息、设置。
|
||||
- 日志中心完整移入“设置 → 本地数据”,保留运行日志、连接日志、设备诊断、筛选和清理能力。
|
||||
- 移除应用本地通知中心。通知依赖应用进程持续转发,不适合关闭后仍要求可用的场景。
|
||||
- 设置页提供全局主题切换:跟随系统、浅色、深色,切换状态立即作用于全部页面。
|
||||
- 隐私说明改为“隐私与数据”,明确节点信息、实例密码和登录会话的实际保存方式。
|
||||
- 节点详情重排为更清晰的状态、连接、SIM、设备和会话分区。
|
||||
- 应用图标重做为黑金风格。
|
||||
- 节点总览展示硬件、控制、SIM、蜂窝链路状态。
|
||||
- 分组头和节点详情头部使用固定尺寸指标卡,加载完成后不会跳变。
|
||||
- 节点总览工具栏统一为 44pt 圆角图标按钮,补齐无障碍标签。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
- 实例信息保存在应用沙盒内的本地数据文件。
|
||||
- 实例密码保存在同一应用沙盒内的私有凭据文件,不使用 iOS Keychain。
|
||||
- 登录会话只保存在进程内存中,应用关闭后清除。
|
||||
- 应用仅连接用户添加的节点地址,允许 HTTP 是为了兼容局域网 SimAdmin;公网地址建议通过 HTTPS 反代暴露。
|
||||
|
||||
## 构建与安装
|
||||
|
||||
从仓库根目录构建:
|
||||
|
||||
```bash
|
||||
./scripts/build-ios-ipa.sh
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
- `build/ipa/MultiSimAdmin.ipa`
|
||||
- `build/ipa/MultiSimAdmin.ipa.sha256`
|
||||
|
||||
0.2.0 构建 3 校验值:
|
||||
|
||||
```text
|
||||
1ddba7399d74705a7d31835dc6dff30c3b2fc3b3cada23c68eaa84a8fb2c832f MultiSimAdmin.ipa
|
||||
```
|
||||
|
||||
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`。构建脚本不要求开发者证书,TrollStore 或自有签名流程可以后续处理。
|
||||
@@ -0,0 +1,50 @@
|
||||
# iOS Native 0.2.1
|
||||
|
||||
0.2.1 构建 6 在 0.2.0 的基础上做界面与图标重构,功能集合不变。应用的整体结构、页面能力、上游白名单和本地数据布局见现行说明 [docs/ios-native.md](ios-native.md),本文只记录本次发布的变化。
|
||||
|
||||
## 本次变更
|
||||
|
||||
- 应用图标重做:改用与 Web 端 favicon 同一套几何的扁平标记——金色 SIM 卡体加三格递增信号,左上保留 SIM 切角。放弃原先的照片级立体卡,29pt 设置图标下也能读清轮廓。
|
||||
- 标签形状统一:详情页与日志页的状态标签改为与节点卡片一致的胶囊,不再一处圆角矩形、一处胶囊。
|
||||
- 深色主题下标签可见性提高,色雾与描边权重上调,避免浅底可读、深底消失。
|
||||
- 消息卡片去掉分隔线并压缩内边距,单屏可见消息数由 2.5 条提升到 3.5 条。
|
||||
- 节点详情的分区卡片收紧行距,"设备"分区同一屏位由 3 行增加到 5 行。
|
||||
- 节点详情头部指标收敛为 4 枚仪表(信号、延迟、CPU、内存)与一行身份摘要(号码、运营商、网络、IP),设备型号、固件、存储、运行时间下沉到分区列表;数据模块由 6 枚胶囊改为一条可用度读数,只在真有模块读不到时展开并点名。
|
||||
- 批量选择勾选框移出实例名称区域,选中与未选中都不再压住文字。
|
||||
- 批量选择工具栏统一为 34pt 图标按钮,全选、删除、退出三个控件等高。
|
||||
- 分组编辑器改为点选即生效,去掉常驻禁用的"移出分组"按钮和说明文字。
|
||||
|
||||
## 文档同步
|
||||
|
||||
本次发布同时补齐了与实现脱节的说明文档:
|
||||
|
||||
- 新增 [docs/ios-native.md](ios-native.md),作为 iOS 应用的现行说明:导航、页面能力、上游接口白名单、本地数据与安全、构建与验证、已确认的移除边界。
|
||||
- [docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md](superpowers/specs/2026-09-08-ios-native-ipa-design.md) 加上实现修订段,逐条标出被推翻的决策(五 Tab、通知中心、Keychain、GRDB/SQLite、操作与审计、设备模块范围、配色)。
|
||||
- [docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md](superpowers/plans/2026-09-08-ios-native-ipa-foundation.md) 标记为已执行完毕,并记录持久化与凭据存储两处偏离。
|
||||
- README 的 iOS 章节改写为当前版本、三个入口、移除能力与构建自检命令。
|
||||
- 删除未被引用的旧图标参考稿 `apps/ios/Assets/AppIcon.svg`,图标参考只保留 `apps/ios/Design/AppIcon.svg`。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
与 0.2.0 一致:实例信息保存在应用沙盒本地数据文件,实例密码保存在同一沙盒的私有凭据文件,不使用 iOS Keychain,登录会话只存在于进程内存。应用仅连接用户添加的节点地址。
|
||||
|
||||
## 构建与安装
|
||||
|
||||
从仓库根目录构建:
|
||||
|
||||
```bash
|
||||
./scripts/build-ios-ipa.sh
|
||||
```
|
||||
|
||||
产物:
|
||||
|
||||
- `build/ipa/MultiSimAdmin.ipa`
|
||||
- `build/ipa/MultiSimAdmin.ipa.sha256`
|
||||
|
||||
0.2.1 构建 6 校验值:
|
||||
|
||||
```text
|
||||
3bef3c678d0bb77a765d4920cb1fe917b82216f158e05b6f08315014e94bd8b7 MultiSimAdmin.ipa
|
||||
```
|
||||
|
||||
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`。构建脚本不要求开发者证书,TrollStore 或自有签名流程可以后续处理。
|
||||
@@ -0,0 +1,124 @@
|
||||
# iOS Native 0.2.2
|
||||
|
||||
0.2.2 把「分组」这个实体从应用里彻底删掉,归类只靠标签;同时完成第二轮配色与图标重构,界面上
|
||||
不再出现蓝色。构建 9 是一轮收尾打磨:修掉消息中心发不出去信的按钮遮挡,补齐按钮层级,统一缺数据
|
||||
读数的写法,并把中性灰阶压成严格等色。构建 10 继续收细节:消息卡片右侧的空洞、批量标签胶囊的
|
||||
假宽度、设置页读不出构建号、两张表单表的标题层级,以及图标石墨底的冷调。页面能力、上游白名单与
|
||||
本地数据布局见现行说明
|
||||
[docs/ios-native.md](ios-native.md),
|
||||
本文只记录本次发布的变化。
|
||||
|
||||
## 本次变更
|
||||
|
||||
- 中性灰阶改为严格 R=G=B:此前所有灰底的蓝通道比红通道高 3~10 级,浅色主题下大面积文字与描边
|
||||
会蒙一层冷调,截图取色仍落在蓝色波段。现在画布、卡片、控件、描边、文字全部等色,画面里
|
||||
只剩红(危险)、橙(告警)、绿(正常)三类状态色。
|
||||
- 延迟量表改为可读量:详情页延迟进度条此前恒为满格,38ms 和 900ms 画出来一模一样。现在与节点
|
||||
卡片同一算法(越短越满,1s 归零),一根条真的在说延迟好坏。
|
||||
- 单位与占位写法统一:卡片里的延迟从 `218ms` 改成 `218 ms`,与概览、详情同一写法;设置页日志
|
||||
计数与日志中心的 HTTP 状态占位也从 `--` 收敛到 `—`。
|
||||
- 表单里成对的控件同尺寸:标签输入框高 52,右侧加号按钮只有 44,两个控件并排时明显一高一矮。
|
||||
现在加号与输入框同高同圆角。
|
||||
- 详情页身份信息对齐方式统一:头部摘要的值此前左对齐成一列,下方分区卡片的值右对齐,同一屏两种
|
||||
排法读起来像两张不同的表。现在两处都靠右。
|
||||
- 修复消息中心无法发信:发送入口原先挂在页面底部的操作条上,而底部标签栏是用 `safeAreaInset`
|
||||
贴在根视图上的,两者叠在同一位置,操作条整条被标签栏盖住,「发送消息」在真机上根本点不到。
|
||||
现在发送与刷新一起放进导航栏右侧,节点总览、设置页同一区域同一套做法。
|
||||
- 主按钮禁用态重新分级:禁用前的主要按钮与普通按钮像素一致,表单里「取消」和「保存实例」分不出
|
||||
主次。现在禁用的主要按钮改用主色低透明度底加同色描边,并去掉投影,看得见但明显弱于可用状态。
|
||||
- 消息行的删除按钮降噪:原先每条消息都带一枚红色实心方块按钮,一屏十几个红块。现在改成一个
|
||||
红色线性图标,保留 44pt 点击区域,只在需要时提示危险语义。
|
||||
- 缺数据读数统一为一根短横:概览与卡片读数此前混用「不可用」和 `--`,四列窄格里既容易被读成
|
||||
故障结论,字号还会被压缩。现在一律用 `—`,并按占位弱色渲染。
|
||||
- 应用图标金色再收一档:底部由深古铜提亮到香槟金,整块金属在高亮壁纸上不再往下沉成棕黄。
|
||||
石墨底同步去掉 1 级的蓝通道偏差,1024 与 120 两档取色都不再落在蓝色波段。
|
||||
|
||||
- 分组概念移除:删除分组视图、分组编辑器与分组仓储,实例模型不再有 `groupId`。升级后首次启动
|
||||
一次性删除 `Documents/instance-groups.json` 与偏好键 `fleet.groupMode`,本机不留死数据。
|
||||
- 标签接管归类:新增「批量标签」表,批量选择后一次给多台节点贴同一个标签;每个标签显示
|
||||
「未贴 / N/M / 全部」三态覆盖度,点击即在整批上加减,也可以在同一张表里新建标签。卡片与
|
||||
标签条上的标签仍然可点,点一下即加入筛选。
|
||||
- 配色重构,全应用零蓝色:主操作、选中态、进度填充一律用石墨墨色,黄铜只作图标点缀;颜色
|
||||
只保留给状态语义(危险红、告警琥珀、成功绿),健康读数回到墨色,深色主题不再整屏泛绿。
|
||||
浅色主题重做为中性冷灰画布加纯白卡片,深色主题为低明度石墨三层。
|
||||
- 选中态可见性:主题切换块与状态图例胶囊原先用 10%~12% 色雾表示选中,浅色下几乎看不出来,
|
||||
改为实心主色加 `onPrimary` 文字,两套主题都一眼可读,且选中不改变控件尺寸。
|
||||
- 应用图标重做:放弃立体卡与投影,改扁平石墨底加黄铜 SIM 面板,三根底部对齐的递增信号条,
|
||||
29pt 下轮廓仍可读;面板边长、圆角、切角与信号条间距可用环境变量调参后重新光栅化。
|
||||
底色与插槽改为严格等值的 R=G=B 中性石墨(原先偏青绿,在亮色壁纸上会被读成蓝),金色
|
||||
下压饱和度、提高明度跨度,从浅金到深古铜,去掉「芥末黄」的塑料感。
|
||||
- 运行概览收紧:检测进度从读数格移进卡片副标题(`刚刚更新 · 已检测 6/6 台`);读数栅格固定
|
||||
八格两行,温度与电量即使整群节点都没上报也保留「不可用」占位,栅格不再在七格与九格之间跳;
|
||||
概览量表健康时用墨色,只在告警与危险时上色。
|
||||
- 实例详情头部重排:四项仪表从四枚嵌套小卡改为两列发丝线栅格,读数在上、进度在下,缺数据时
|
||||
保留空轨道,数据到位后卡片高度不变,修掉 0.2.1「加载完成后头部比下方窄一圈」的问题;身份摘要
|
||||
去掉内嵌底色,改用顶部发丝线分隔。
|
||||
- 字段名补全:详情页里 `name`、`disk_total_bytes`、`disk_available_bytes`、`percent` 四个键
|
||||
此前直接把原始键名渲染到界面上,现补为「设备名称」「存储总量」「可用存储」「使用率」并排进
|
||||
固定顺序。
|
||||
- 标签与方向胶囊中性化:`AITag` 拆成两类——不传状态色时是中性归类胶囊(浅底、发丝描边、墨色
|
||||
文字,可带图标),传状态色时才用色雾。详情页标签因此不再显示成近乎禁用的黑雾块,并与节点
|
||||
卡片同一形状同一图标;消息中心的「收件 / 发件」由绿色改回中性胶囊配上下箭头,绿色重新只
|
||||
表示健康状态。
|
||||
- 批量标签表在没有任何勾选时给出「先在节点列表勾选要归类的节点」提示,不再留一行空白。
|
||||
同时这段状态下标签胶囊不再渲染成 `Button`:系统禁用态会把整枚胶囊压成灰块,看起来像加载
|
||||
失败,现按中性标签原样显示,只把「新建标签并贴上」的输入与按钮置灰并说明原因。
|
||||
- 版本号显示去掉括号:设置页「应用信息」只显示 `0.2.2`。
|
||||
|
||||
## 构建 10
|
||||
|
||||
- 消息卡片右侧的空洞修掉:删除按钮此前和摘要挤在同一行 `HStack` 里,它自带 44pt 点击区,等于把
|
||||
这一行撑高约 22pt,卡片下半截全是空白,一屏只能放三张。现在摘要行按正文列宽排版,删除按钮用
|
||||
`overlay` 悬在右侧并溢出到卡片留白里,点击区仍是 44pt,一屏回到四张。
|
||||
- 批量标签的胶囊改成贴合文字宽度:原先用 `LazyVGrid` 等宽栅格,短标签被拉到 112pt 一格,看起来
|
||||
像整片禁用的灰块。现在换成流式布局(`AIFlowLayout`),胶囊只占文字加内边距,节点页的标签条
|
||||
复用同一份实现,删掉了原来那份重复的布局代码。
|
||||
- 设置页显示构建号:同一营销版本会连续出多个包,只写 `0.2.2` 分不清设备上装的是哪个。应用信息
|
||||
现在是「版本 0.2.2 / 构建 10 / 系统 iOS 26.3.1」三行,全程不带括号。
|
||||
- 两张表单表统一行内标题:「添加实例」和「发送消息」用大标题,标题下方留出一整条空白,而同类的
|
||||
「批量标签」已经是行内样式。现在三张表一致。
|
||||
- 节点卡片的空态文案不再自相矛盾:卡片下方的量表条确实会返回运行时数据,空态却写「节点未返回
|
||||
运行信息」。改为「节点未返回设备信息」,与实际缺少的字段一致。
|
||||
- 图标底色压暖:石墨底原先是数学意义上的中性灰(R=G=B),在 iOS 浅色壁纸旁仍会被对比成冷调,
|
||||
这是用户反馈「图标是蓝色」的直接来源(旧包 0.2.1 的图标确实有 72% 像素落在蓝色波段)。现在
|
||||
底色、面板阴影与插槽统一带 3~4 级暖偏移,取色为 `rgb(47,45,42)`,全图 0 蓝色像素。
|
||||
|
||||
## 文档同步
|
||||
|
||||
- 新增本文,[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 构建 10 校验值:
|
||||
|
||||
```text
|
||||
5018b9b4a1b236892b539746994176e1421a2cbe6ec24fdaac540d9bcbcf5fdd MultiSimAdmin.ipa
|
||||
```
|
||||
|
||||
IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`,未签名,
|
||||
校验方式:
|
||||
|
||||
```bash
|
||||
cd build/ipa && shasum -a 256 -c MultiSimAdmin.ipa.sha256
|
||||
```
|
||||
@@ -0,0 +1,205 @@
|
||||
# iOS 原生应用说明(当前实现)
|
||||
|
||||
本文是 iPhone 应用的唯一现行说明,内容与仓库代码一致。历史设计稿和执行计划保留在
|
||||
`docs/superpowers/` 下,其中已被实现推翻的决策在本文第 9 节逐条列出。
|
||||
|
||||
- 当前版本:`0.2.2`,构建号 10
|
||||
- 承载分支:`ios-native-ipa`
|
||||
- 运行要求:iOS 16.0 及以上,`arm64`,Bundle ID `fun.chick.multi-simadmin`
|
||||
- 产物:未签名 `MultiSimAdmin.ipa`,由 TrollStore 或用户自有签名流程安装
|
||||
|
||||
## 1. 形态
|
||||
|
||||
独立 iPhone 应用。应用直接以 HTTP/HTTPS 访问用户添加的每个 SimAdmin 节点,不经过
|
||||
8788 控制台,不含 WebView,不内嵌 Node.js,也不与 Web 端共享数据库或进程。Web 前端和
|
||||
Node 控制面仍然独立存在,两者只共享上游接口契约。
|
||||
|
||||
## 2. 导航
|
||||
|
||||
底部固定三个入口,高度 62pt(`AITabBar`):
|
||||
|
||||
| 入口 | 页面 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| 节点 | `FleetView` | 节点总览、运行概览、标签筛选与批量归类、批量选择、添加与编辑实例 |
|
||||
| 消息 | `MessagesView` | 跨实例短信读取、按实例与方向筛选、搜索、发送、删除 |
|
||||
| 设置 | `SettingsView` | 主题外观、应用信息、本地数据(进入日志中心)、隐私与数据、数据清理 |
|
||||
|
||||
实例详情 `InstanceDetailView` 由节点卡片进入;日志中心 `LogsView` 只出现在
|
||||
「设置 → 本地数据」里,不占用底部入口。启动参数 `--tab-messages`、`--tab-settings`
|
||||
可直达对应 Tab,`--tab-detail`、`--tab-logs` 只在 Debug 构建生效,供逐页截图验证使用。
|
||||
|
||||
## 3. 页面能力
|
||||
|
||||
### 节点总览
|
||||
|
||||
- 运行概览:在线率与异常节点两枚大字结论、状态分布条与图例、八格两行的群集读数(信号、延迟、
|
||||
存储、流量、CPU、内存、温度、电量),可折叠,折叠时保留前四格。检测进度与更新时间写在卡片
|
||||
副标题里(`刚刚更新 · 已检测 6/6 台`),不占读数格;整群节点没有上报的读数保留占位,统一写成
|
||||
一根短横 `—`,避免在四列窄格里被读成故障结论;栅格不会在七格与九格之间跳动。
|
||||
- 标签归类:标签是唯一归类手段,分组概念已在 0.2.2 移除。标签条支持多选叠加,胶囊上直接显示
|
||||
命中台数,可单个取消或全部清除;卡片内的标签同样可点,点一下即加入筛选。
|
||||
- 批量标签:批量选择后一次给多台节点贴同一个标签,每个标签显示「未贴 / N/M / 全部」三态覆盖度,
|
||||
点击即在整批上加减;可在同一张表里新建标签。
|
||||
- 搜索:匹配名称、地址、运营商与标签。
|
||||
- 节点卡片:顶部四条量表(信号、延迟、CPU、内存)配发丝进度,下面是号码、运营商、设备、存储、
|
||||
温度、电量、运行时间等指标,超出一屏时折叠为「另有 N 项指标」。健康读数不着色,只有越过阈值
|
||||
才出现告警或危险色。
|
||||
- 批量选择:工具栏为 34pt 图标按钮(全选、删除、退出等高),勾选列独立于名称区域,
|
||||
选中态不遮挡文字。
|
||||
- 实例增删改:地址、名称、标签、访问凭据;删除前确认,并一并清除本地登录信息。
|
||||
|
||||
### 实例详情
|
||||
|
||||
- 头部卡片:地址与连接状态、四项仪表(信号、延迟、CPU、内存)、身份摘要(号码、运营商、网络、
|
||||
IP)、模块可用度读数、标签。四项仪表排成两列发丝线栅格,读数在上、进度在下,缺数据时保留
|
||||
空轨道,数据到位后卡片高度不变。
|
||||
- 数据模块:设备、SIM、蜂窝网络、系统资源、CPU、连接状态共 6 个分区,逐模块独立加载,
|
||||
单模块失败只标记该模块,不影响其余分区。
|
||||
- 敏感值(IMEI、ICCID 等)默认隐藏,需要显式开启显示,离开页面后自动恢复隐藏。
|
||||
- 本机保存的配置:地址、凭据状态、标签,以及登录、登出、重新检测。
|
||||
|
||||
### 消息中心
|
||||
|
||||
逐实例读取 `/api/sms/list` 后在本地合并,不做跨设备中转:
|
||||
|
||||
- 列表按时间排序,显示来源实例、方向、号码、正文与相对时间。
|
||||
- 支持按实例筛选、按上行/下行筛选、按号码或内容搜索。
|
||||
- 发送与刷新一起放在导航栏右侧,页脚不再另挂操作条;发送写入目标实例,失败可重试;
|
||||
单条删除走该实例的上游接口,行内只留一枚红色线性图标。
|
||||
|
||||
### 日志中心与设置
|
||||
|
||||
- 日志中心:运行日志统一时间线、连接探测记录(结果、错误码、耗时)、设备诊断快照;
|
||||
支持按级别、来源、实例、结果筛选与关键字搜索,支持清理过期连接日志。
|
||||
- 设置:主题外观(跟随系统、浅色、深色,即时作用于全部页面)、应用信息与系统版本、
|
||||
本地数据统计、隐私与数据说明、清除运行日志(不可恢复确认)。
|
||||
|
||||
## 4. 上游访问
|
||||
|
||||
只允许固定白名单,不存在任意 URL 代理:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/health` | 可达性与版本 |
|
||||
| GET | `/api/device` | 设备信息 |
|
||||
| GET | `/api/sim` | SIM 与号码 |
|
||||
| GET | `/api/network` | 蜂窝网络与信号 |
|
||||
| GET | `/api/network/signal-strength` | 信号强度明细 |
|
||||
| GET | `/api/stats` | 系统资源 |
|
||||
| GET | `/api/stats/cpu` | CPU 明细 |
|
||||
| GET | `/api/connectivity` | 连接状态 |
|
||||
| GET | `/api/sms/list` | 短信列表(limit、offset、direction) |
|
||||
| POST | `/api/sms/send` | 发送短信 |
|
||||
| POST | `/api/sms/batch-delete` | 批量删除短信 |
|
||||
| POST | `/api/auth/login` | 使用实例密码换取会话 |
|
||||
| POST | `/api/auth/logout` | 注销会话 |
|
||||
|
||||
请求超时 30 秒,单响应体上限 262144 字节,错误统一映射为请求非法、需要登录、权限受限、不支持、
|
||||
超时、传输失败、响应非法七类可读文案。会话 Cookie 按实例隔离保存在内存中,并绑定创建时
|
||||
的节点地址,地址改变后旧 Cookie 立即失效。应用不请求定位、相机、麦克风、通讯录或后台
|
||||
刷新权限,不声明任何后台模式。
|
||||
|
||||
## 5. 本地数据与安全
|
||||
|
||||
全部数据落在应用沙盒 `Documents` 目录,无 Keychain、无 SQLite、无服务端上传:
|
||||
|
||||
| 文件 | 内容 |
|
||||
| --- | --- |
|
||||
| `instances.json` | 实例名称、地址、标签、修订号 |
|
||||
| `credentials.json` | 实例访问密码 |
|
||||
| `log-center.json` | 运行日志、连接探测记录、诊断快照缓存 |
|
||||
|
||||
0.2.1 及更早版本写过的 `instance-groups.json` 与偏好键 `fleet.groupMode` 在升级后的首次启动
|
||||
一次性删除。主题偏好存在 `UserDefaults`(键 `aiThemeMode`)。登录会话只存在于进程内存,应用退出即消失。
|
||||
密码与 Cookie 不写日志、不进导出内容。删除应用即彻底清除本机数据。允许 HTTP 明文访问是
|
||||
为了兼容局域网 SimAdmin,公网节点应由用户自行通过 HTTPS 反代暴露。
|
||||
|
||||
## 6. 设计语言
|
||||
|
||||
界面不含蓝色。品牌色是石墨与黄铜:浅色画布中性冷灰 `#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. 构建与产物
|
||||
|
||||
```bash
|
||||
./scripts/build-ios-ipa.sh
|
||||
```
|
||||
|
||||
流程:`xcodebuild -scheme MultiSimAdmin -configuration Release`(`generic/platform=iOS`)编译
|
||||
可执行文件 → 手工组装 `Payload/MultiSimAdmin.app` → 用 `xcrun actool` 编译 `Assets.xcassets`
|
||||
得到 `Assets.car` 并合并图标元数据到 `Info.plist` → `zip` 打包 → 生成 `.sha256`。产物:
|
||||
|
||||
- `build/ipa/MultiSimAdmin.ipa`
|
||||
- `build/ipa/MultiSimAdmin.ipa.sha256`
|
||||
|
||||
脚本不需要证书与描述文件,不做任何签名改动;`Assets.car` 缺失时直接失败退出,避免发布没有
|
||||
图标的包。
|
||||
|
||||
应用图标由脚本光栅化生成,不使用手工位图:
|
||||
|
||||
```bash
|
||||
cd apps/ios && swift Design/generate_app_icon.swift \
|
||||
Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png
|
||||
```
|
||||
|
||||
图标为扁平构图,不用立体卡与投影:石墨底、黄铜 SIM 面板(切角在左上)、三根底部对齐的递增
|
||||
信号条,29pt 尺寸下轮廓仍然可读。`apps/ios/Design/AppIcon.svg` 是同构图参考稿,实际构建使用
|
||||
由 `Design/generate_app_icon.swift` 光栅化出的 PNG,面板边长、圆角、切角与信号条间距都可用
|
||||
环境变量调整。
|
||||
|
||||
## 8. 验证方式
|
||||
|
||||
```bash
|
||||
cd apps/ios
|
||||
swift build
|
||||
swift test # 48 个用例
|
||||
xcodebuild -scheme MultiSimAdmin \
|
||||
-destination 'platform=iOS Simulator,id=<UDID>' \
|
||||
-configuration Debug build
|
||||
```
|
||||
|
||||
单元测试覆盖实例仓储与修订冲突、地址规范化与去重、标签归一化、上游路由白名单、会话隔离、
|
||||
连接探测分类、短信解析与分页、设备快照解析、日志仓储与清理、主题模式持久化、时间相对显示、
|
||||
版本号契约。界面改动在 iPhone 17 Pro 模拟器上以截图逐页核对浅色与深色两套主题,重点检查控件
|
||||
等高、文字不溢出、按钮在深色下不消失。
|
||||
|
||||
## 9. 与设计稿的偏离
|
||||
|
||||
`docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md` 的以下决策已被实现推翻,该文档
|
||||
只作为历史背景保留:
|
||||
|
||||
| 原设计 | 当前实现 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| 底部五 Tab:节点/消息/通知/日志/设置 | 三 Tab:节点/消息/设置 | 应用内通知中心依赖进程常驻,关闭后不可用;日志属于低频排查,收进设置 |
|
||||
| 通知通道、规则、投递队列、立即投递 | 未实现 | 同上,移除后不再保留只读残壳 |
|
||||
| 实例密码存 iOS Keychain | 沙盒内 `credentials.json` | Keychain 在 TrollStore 环境下读写不稳定 |
|
||||
| GRDB + SQLite 控制面库 | 沙盒内 JSON 文件 | 去掉第三方依赖,数据结构与备份语义更直观 |
|
||||
| 操作目录、任务、审计、维护、备份 | 未实现 | 高风险写操作与常驻调度不在 iPhone 前台场景内 |
|
||||
| 设备模块 13 项(含通话、eSIM、OTA、WiFi Calling、自动化) | 6 个只读模块 + 短信读写 | 上游只保留有稳定读取价值的接口,其余按需在后续版本评估 |
|
||||
| 浅灰画布、青绿主按钮 | 深浅双主题、石墨墨色主操作、黄铜点缀 | 0.2.x 视觉重构结论,用户明确排除蓝色 |
|
||||
| 实例分组(分组视图、分组编辑器、分组仓储) | 标签归类 + 批量标签 | 分组与标签职责重复,分组作为实体在 0.2.2 移除 |
|
||||
|
||||
## 10. 已确认的移除边界
|
||||
|
||||
不实现:应用启动后的周期轮询、后台任务与静默推送、Cron 与自动投递、自动备份与自动日志修剪、
|
||||
控制台自更新、多用户与远程访问、全局访问密码。
|
||||
|
||||
保留:进入前台与下拉刷新触发的一次同步、页面可见时的按需刷新、用户主动发起的登录、检测、
|
||||
发送、删除与日志清理。
|
||||
|
||||
## 11. 文档索引
|
||||
|
||||
- [docs/ios-native.md](ios-native.md):本文,现行说明
|
||||
- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 构建 10 发布说明与校验值
|
||||
- [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):历史设计稿
|
||||
- [docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md](superpowers/plans/2026-09-08-ios-native-ipa-foundation.md):地基执行计划(已完成)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,25 @@
|
||||
|
||||
日期:2026-09-08
|
||||
|
||||
> **实现修订(2026-09-14,0.2.1 build 6)**
|
||||
>
|
||||
> 本文是改写启动时的设计稿,保留作为历史背景。以下决策在实现过程中被推翻,现行事实以
|
||||
> [docs/ios-native.md](../../ios-native.md) 为准,正文中的旧描述不再单独改写:
|
||||
>
|
||||
> - 底部导航为**节点、消息、设置**三个入口,不是本文的五个 Tab。
|
||||
> - **应用内通知中心整体移除**:通知依赖常驻进程转发,应用关闭后无法工作,因此通道、规则、
|
||||
> 投递队列、手动投递均不实现。
|
||||
> - **日志中心不是独立 Tab**,收纳在「设置 → 本地数据」内。
|
||||
> - **不使用 iOS Keychain**:TrollStore 环境下 Keychain 读写不稳定,实例密码改存应用沙盒
|
||||
> `Documents/credentials.json`,仅内存持有会话 Cookie。
|
||||
> - **不使用 GRDB/SQLite**:本地数据改为四个 JSON 文件(`instances.json`、
|
||||
> `instance-groups.json`、`credentials.json`、`log-center.json`),不引入第三方依赖。
|
||||
> - **操作目录、任务、审计、维护、备份、恢复未实现**:iPhone 前台场景不承载高风险写操作与
|
||||
> 调度型能力,本文对应章节按未落地处理。
|
||||
> - 设备模块落地为 6 个只读分区(设备、SIM、蜂窝网络、系统资源、CPU、连接状态)加短信读写,
|
||||
> 本文列出的通话、eSIM、OTA、WiFi Calling、自动化历史未纳入当前版本。
|
||||
> - 配色从「浅灰画布 + 青绿主按钮」改为深墨蓝与金色的品牌对,浅色用中性冷灰画布,主操作为蓝色。
|
||||
|
||||
## 目标
|
||||
|
||||
把 multi-simadmin 的管理能力改写为独立 iPhone 应用,而不是为现有 Web 服务做一个远程客户端或 WebView 壳。应用面向 iOS 16+ 和 TrollStore 安装环境,构建流程产出未签名 IPA;Apple 证书、开发者账号、TestFlight 和上架签名不在本项目范围内。
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
IOS_ROOT="$ROOT/apps/ios"
|
||||
BUILD_ROOT="$ROOT/build/ipa"
|
||||
APP_NAME="MultiSimAdmin"
|
||||
DERIVED_ROOT="${MULTI_SIMADMIN_IOS_DERIVED:-$IOS_ROOT/.build/ios-derived}"
|
||||
|
||||
cd "$IOS_ROOT"
|
||||
xcodebuild -scheme "$APP_NAME" \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-configuration Release \
|
||||
-derivedDataPath "$DERIVED_ROOT" \
|
||||
build
|
||||
|
||||
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
|
||||
find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
|
||||
cp "$DERIVED_ROOT/Build/Products/Release-iphoneos/$APP_NAME" \
|
||||
"$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
|
||||
cp "Sources/$APP_NAME/Resources/Info.plist" \
|
||||
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
|
||||
xcrun actool \
|
||||
--compile "$BUILD_ROOT/Payload/$APP_NAME.app" \
|
||||
--platform iphoneos \
|
||||
--minimum-deployment-target 16.0 \
|
||||
--app-icon AppIcon \
|
||||
--output-format human-readable-text \
|
||||
--output-partial-info-plist "$BUILD_ROOT/AssetCatalog-Info.plist" \
|
||||
"$IOS_ROOT/Sources/$APP_NAME/Resources/Assets.xcassets"
|
||||
|
||||
if [[ ! -f "$BUILD_ROOT/Payload/$APP_NAME.app/Assets.car" ]]; then
|
||||
echo 'Assets.car missing: application icon was not compiled' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Merge '$BUILD_ROOT/AssetCatalog-Info.plist'" \
|
||||
"$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
|
||||
|
||||
plutil -lint "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
|
||||
cd "$BUILD_ROOT"
|
||||
zip -qry "$APP_NAME.ipa" Payload
|
||||
shasum -a 256 "$APP_NAME.ipa" > "$APP_NAME.ipa.sha256"
|
||||
|
||||
printf 'IPA: %s/%s.ipa\n' "$BUILD_ROOT" "$APP_NAME"
|
||||
printf 'Size: %s bytes\n' "$(stat -f %z "$APP_NAME.ipa")"
|
||||
printf 'SHA-256: '
|
||||
cat "$APP_NAME.ipa.sha256"
|
||||
Reference in New Issue
Block a user