Compare commits

...
23 Commits
Author SHA1 Message Date
chick 802ddd1102 release(ios): 0.2.2 构建 10 修消息卡空洞、标签胶囊假宽与表单标题层级
- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高
- 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现
- 设置页应用信息补「构建」一行,版本号全程不带括号
- 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带
- 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾
- 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
2026-09-15 13:03:23 +08:00
chick c3d17b3daf release(ios): 0.2.2 构建 9 修复消息发送遮挡并中性化石墨灰阶
- 消息中心发送入口从被标签栏盖住的底部操作条移到导航栏右侧,发信恢复可用
- 停用态主按钮保留主色淡底与同色描边,与次级按钮拉开层级
- 消息行删除改安静线性图标,去掉一屏红块
- 缺数据读数统一为短横,卡片延迟补空格单位
- 中性灰阶改为严格 R=G=B,浅色主题不再读出冷调;图标石墨底同步去蓝
- 详情页延迟量表改用与卡片同一算法,身份信息值改为右对齐
- 标签加号按钮与输入框同高同圆角
2026-09-15 08:19:18 +08:00
chick 9ad0c46088 release(ios): 0.2.2 构建 8 中性化批量标签禁用态并去蓝化图标底色
- FleetTagBatchSheet:无勾选时标签胶囊不再走 Button 禁用态(系统会压成灰块,像加载
  失败),改为中性标签原样渲染;覆盖度文案留空,图标改 tag;新建标签输入与按钮置灰
  并补一句「勾选节点后即可新建标签并批量贴上」。
- 应用图标:底色与插槽从偏青绿的石墨(R>G=B,亮壁纸上读作蓝)改为严格等值中性灰
  20/19/10,金色跨度拉大(#FADD98 -> #C78D2F)去掉芥末黄塑料感;AppIcon.svg 同步。
- 版本号:marketing 0.2.2 不带括号,构建号 7 -> 8;文档里 0.2.x (N) 一律改写为
  「0.2.x 构建 N」,README 与现行说明同步。
2026-09-15 05:07:03 +08:00
chick 36ce791215 polish(ios): 中性化标签与方向胶囊并收紧详情头部间距
- AITag 拆为两类:不传状态色时是中性归类胶囊(tile 底、发丝描边、墨字,可带 10pt 图标),
  传状态色时才用 14% 色雾与 30% 描边。颜色只服务状态语义。
- 详情页标签改用 AITag(tag, systemImage: "tag"),与节点卡片同一枚形状;头部 VStack 间距
  14 -> 12,读数格 minHeight 62 -> 56,加载完成后不再明显小于下方卡片。
- 消息中心「收件 / 发件」由绿/墨配色改回中性胶囊配 arrow.down.left / arrow.up.right,
  绿色重新只表示健康状态。
- 批量标签表在无勾选时给出「先在节点列表勾选要归类的节点」,不再留空白行。
- 文档:0.2.2 增量记录与校验值更新为 0e4ffb74…;版本号后不再带括号,
  0.2.0/0.2.1/0.2.2 与 README、现行说明统一改写为「0.2.x 构建 N」。
2026-09-15 03:56:31 +08:00
chick eb880e0d14 release(ios): 0.2.2 移除分组改用标签归类并完成石墨配色重构
- 删除分组实体:分组视图/编辑器/仓储与 groupId 全部移除,升级首启清理
  instance-groups.json 与 fleet.groupMode
- 新增批量标签表:三态覆盖度(未贴 / N/M / 全部),整批加减与新建标签
- 配色重构,全应用零蓝色:石墨墨色承担主操作与选中态,颜色只留给状态语义;
  浅色改中性冷灰,深色改低明度石墨三层
- 选中态改实心主色 + onPrimary,替换不可读的低透明度色雾(主题块、图例胶囊)
- 应用图标重做为扁平石墨底 + 黄铜 SIM 面板,参数化光栅化脚本与参考稿
- 运行概览:检测进度并入副标题,读数栅格固定八格两行并保留不可用占位
- 实例详情:仪表改两列发丝线栅格,缺数据留轨道,修掉头部高度回弹;
  补齐设备名称/存储总量/可用存储/使用率四个字段名
- 版本号显示去掉括号,设置页只显示 0.2.2
- 文档:新增 docs/ios-native-0.2.2.md,同步 ios-native.md 与两处 README
2026-09-15 02:23:24 +08:00
chick ec3ffb9db3 docs(ios): 同步 0.2.x 实况文档与规划记录 2026-09-14 18:22:23 +08:00
chick 3607eddc9f release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
2026-09-14 16:30:39 +08:00
chick 27687868ef fix(ios): unify toolbar controls and release build 3 2026-09-13 01:15:41 +08:00
chick 2ff23d1fb0 fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid
- Add link-chain state to fleet cards and parse hardware/control/SIM/cellular
- Replace the wrapped icon with a full-bleed app icon
- Bump the IPA build number
2026-09-13 00:46:26 +08:00
chick 409345cba3 docs(ios): document the 0.2.0 release 2026-09-12 17:18:54 +08:00
chick e01a105645 feat(ios): simplify navigation and ship 0.2.0
Move diagnostics into settings, remove the app-local notification module, unify the global theme switcher, refine privacy copy and the instance detail experience, and rebuild the app icon.
2026-09-12 17:10:02 +08:00
chick 20632be86a feat(ios): unify action hierarchy and surfaces
Refine primary and toolbar actions, align row buttons, normalize status surfaces, and split storage from destructive cleanup. Verified with Swift tests and the simulator build.
2026-09-12 16:15:57 +08:00
chick 0f2866a853 feat(ios): refine native interface and app icon
Unify bottom actions, selection controls, navigation structure, spacing, and theme colors across iOS pages. Replace the app icon with a higher-contrast native-style asset and include its source SVG.
2026-09-12 14:09:02 +08:00
chick 31b2f33d15 feat(ios): refine adaptive theme and unified controls 2026-09-10 03:19:03 +08:00
chick c841748971 feat(ios): unify list cards and loading states 2026-09-10 01:35:12 +08:00
chick 995206ac23 feat(ios): refine native ui controls 2026-09-09 11:14:39 +08:00
chick c568e496b2 feat(ios): add native settings and storage controls 2026-09-09 02:06:34 +08:00
chick 9c3186252b feat(ios): add cross-instance log center 2026-09-09 01:53:40 +08:00
chick cf187e42fa feat(ios): add cross-instance notification center 2026-09-09 01:13:45 +08:00
chick 84ec3678c0 feat(ios): add device details and reliable ipa build 2026-09-09 00:46:28 +08:00
chick 7778693807 feat(ios): add cross-instance message center 2026-09-09 00:18:56 +08:00
chick 54eb8dcb86 feat(ios): add native app foundation and unsigned ipa build 2026-09-09 00:05:51 +08:00
chick fda3e11f1e docs: add iOS native IPA foundation plan 2026-09-08 21:22:38 +08:00
74 changed files with 11907 additions and 1 deletions
+8 -1
View File
@@ -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
+24
View File
@@ -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 地址。
+1
View File
@@ -0,0 +1 @@
.build/
+31
View File
@@ -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

+246
View File
@@ -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)
+24
View File
@@ -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"]
)
]
)
+56
View File
@@ -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
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

@@ -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")
}
}
+44
View File
@@ -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 或自有签名流程可以后续处理。
+50
View File
@@ -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 或自有签名流程可以后续处理。
+124
View File
@@ -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
```
+205
View File
@@ -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 和上架签名不在本项目范围内。
+49
View File
@@ -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"