release(ios): 发布 0.2.1 并重构图标与界面细节
- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读 - 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失 - 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升 - 详情头部指标精简为 4 行,数据模块改为单条读数 - 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt - 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
## iOS Native 应用
|
||||
|
||||
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用。当前 0.2.0 保留节点总览、跨实例短信、实例详情、日志诊断、全局外观切换和本地数据管理;构建方式见 [docs/ios-native-0.2.0.md](docs/ios-native-0.2.0.md)。
|
||||
`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用。当前 0.2.1 保留节点总览、标签筛选与分组、跨实例短信、实例详情、日志诊断、全局外观切换和本地数据管理;构建方式见 [docs/ios-native-0.2.1.md](docs/ios-native-0.2.1.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
|
||||
+37
-19
@@ -1,26 +1,44 @@
|
||||
<!-- 设计参考稿,构图与 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="gold" x1="0.18" y1="0.04" x2="0.82" y2="0.96">
|
||||
<stop offset="0" stop-color="#FFE9B0"/>
|
||||
<stop offset="0.24" stop-color="#F4C767"/>
|
||||
<stop offset="0.68" stop-color="#DA9F45"/>
|
||||
<stop offset="1" stop-color="#B77B2D"/>
|
||||
<linearGradient id="field" x1="0.10" y1="0" x2="0.92" y2="1">
|
||||
<stop offset="0" stop-color="#1C4280"/>
|
||||
<stop offset="0.46" stop-color="#0D2450"/>
|
||||
<stop offset="1" stop-color="#040B1B"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0.18" y1="0" x2="0.82" y2="0.88">
|
||||
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.24"/>
|
||||
<stop offset="0.52" stop-color="#FFFFFF" stop-opacity="0.04"/>
|
||||
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
||||
<radialGradient id="key" cx="0.26" cy="-0.04" r="0.92">
|
||||
<stop offset="0" stop-color="#96C4FF" stop-opacity="0.25"/>
|
||||
<stop offset="0.55" stop-color="#70A2E6" stop-opacity="0.08"/>
|
||||
<stop offset="1" stop-color="#70A2E6" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="vig" cx="0.5" cy="0.52" r="0.78">
|
||||
<stop offset="0.55" stop-color="#020712" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#020712" stop-opacity="0.59"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#FFF4CD"/>
|
||||
<stop offset="0.30" stop-color="#FACE6A"/>
|
||||
<stop offset="0.62" stop-color="#D69628"/>
|
||||
<stop offset="1" stop-color="#925C10"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="bar" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#2A2F37"/>
|
||||
<stop offset="1" stop-color="#111419"/>
|
||||
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#050D1E"/>
|
||||
<stop offset="0.58" stop-color="#091732"/>
|
||||
<stop offset="1" stop-color="#112649"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1024" height="1024" fill="url(#gold)"/>
|
||||
<rect width="1024" height="1024" fill="url(#sheen)"/>
|
||||
<g fill="url(#bar)">
|
||||
<rect x="317" y="602" width="108" height="188" rx="54"/>
|
||||
<rect x="458" y="475" width="108" height="315" rx="54"/>
|
||||
<rect x="599" y="348" width="108" height="442" rx="54"/>
|
||||
</g>
|
||||
|
||||
<rect width="1024" height="1024" fill="url(#field)"/>
|
||||
<rect width="1024" height="1024" fill="url(#key)"/>
|
||||
<rect width="1024" height="1024" fill="url(#vig)"/>
|
||||
|
||||
<!-- SIM 卡体:左上切角,其余三角大圆角 -->
|
||||
<path d="M494.5,218 L688,218 Q822,218 822,352 L822,704 Q822,838 688,838 L336,838
|
||||
Q202,838 202,704 L202,510.5 Q202,420 266,356 L340,282 Q404,218 494.5,218 Z"
|
||||
fill="url(#gold)"/>
|
||||
|
||||
<!-- 三格信号:底边对齐,右高,读作多卡队列健康度 -->
|
||||
<rect x="318" y="546" width="88" height="142" rx="26" fill="url(#slot)"/>
|
||||
<rect x="468" y="446" width="88" height="242" rx="26" fill="url(#slot)"/>
|
||||
<rect x="618" y="346" width="88" height="342" rx="26" fill="url(#slot)"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,276 @@
|
||||
// Renders the 1024x1024 MultiSimAdmin app icon with CoreGraphics only.
|
||||
// Usage: swift Design/generate_app_icon.swift <output.png>
|
||||
//
|
||||
// The mark is the same geometry as the web favicon: a gold SIM plate carrying
|
||||
// three ascending signal bars. One flat silhouette, one light source, and no
|
||||
// photographic detail, so it still reads at the 29pt settings size. The plate
|
||||
// keeps the classic SIM chamfer, moved to the top-left so the tallest bar can
|
||||
// sit under the intact top-right corner.
|
||||
//
|
||||
// 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 one cut top corner.
|
||||
func platePath(_ rect: CGRect, corner: CGFloat, chamfer: CGFloat) -> CGPath {
|
||||
let bevel = corner * 0.28
|
||||
let vertices: [(point: CGPoint, radius: CGFloat)] = [
|
||||
(CGPoint(x: rect.minX + chamfer, y: rect.minY), bevel),
|
||||
(CGPoint(x: rect.maxX, y: rect.minY), corner),
|
||||
(CGPoint(x: rect.maxX, y: rect.maxY), corner),
|
||||
(CGPoint(x: rect.minX, y: rect.maxY), corner),
|
||||
(CGPoint(x: rect.minX, y: rect.minY + chamfer), bevel),
|
||||
]
|
||||
let path = CGMutablePath()
|
||||
path.move(to: vertices[0].point)
|
||||
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, dead 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)),
|
||||
[
|
||||
(0.00, (28, 66, 128, 255)),
|
||||
(0.46, (13, 36, 80, 255)),
|
||||
(1.00, (4, 11, 27, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: side * 0.10, y: 0),
|
||||
CGPoint(x: side * 0.92, y: side)
|
||||
)
|
||||
fillRadial(
|
||||
CGPoint(x: side * 0.26, y: -side * 0.04), CGFloat(side * 0.92),
|
||||
[(0.0, (150, 196, 255, 64)), (0.55, (112, 162, 230, 20)), (1.0, (112, 162, 230, 0))] as [Stop]
|
||||
)
|
||||
fillRadial(
|
||||
CGPoint(x: side * 0.5, y: side * 0.52), CGFloat(side * 0.78),
|
||||
[(0.55, (2, 7, 18, 0)), (1.0, (2, 7, 18, 150))] as [Stop]
|
||||
)
|
||||
|
||||
// MARK: - Plate
|
||||
|
||||
let plateSide = envDouble("ICON_PLATE_SIDE", 620)
|
||||
let plateCenterY = envDouble("ICON_PLATE_Y", 528)
|
||||
let plateCorner = envDouble("ICON_PLATE_CORNER", 134)
|
||||
let plateChamfer = envDouble("ICON_PLATE_CHAMFER", 202)
|
||||
let plateRect = CGRect(
|
||||
x: (side - plateSide) / 2,
|
||||
y: plateCenterY - plateSide / 2,
|
||||
width: plateSide,
|
||||
height: plateSide
|
||||
)
|
||||
let plate = platePath(plateRect, corner: plateCorner, chamfer: plateChamfer)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: -30), blur: 62, color: rgb(2, 8, 20, 168))
|
||||
ctx.addPath(plate)
|
||||
ctx.setFillColor(rgb(0, 0, 0))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
fillLinear(
|
||||
plate,
|
||||
[
|
||||
(0.00, (255, 244, 205, 255)),
|
||||
(0.30, (250, 206, 106, 255)),
|
||||
(0.62, (214, 150, 40, 255)),
|
||||
(1.00, (146, 92, 16, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: plateRect.minX, y: plateRect.minY),
|
||||
CGPoint(x: plateRect.maxX, y: plateRect.maxY)
|
||||
)
|
||||
|
||||
directionalRim(
|
||||
plate, plateRect, plateSide * 0.016,
|
||||
[
|
||||
(0.00, (255, 251, 232, 230)),
|
||||
(0.40, (255, 240, 190, 66)),
|
||||
(0.74, (255, 232, 170, 0)),
|
||||
] as [Stop]
|
||||
)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.addPath(plate)
|
||||
ctx.clip()
|
||||
ctx.move(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.52))
|
||||
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.10))
|
||||
ctx.addLine(to: CGPoint(x: plateRect.maxX + 40, y: plateRect.minY + plateSide * 0.22))
|
||||
ctx.addLine(to: CGPoint(x: plateRect.minX - 40, y: plateRect.minY + plateSide * 0.64))
|
||||
ctx.closePath()
|
||||
ctx.setFillColor(rgb(255, 252, 236, 5))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
// MARK: - Signal bars
|
||||
|
||||
let barWidth = envDouble("ICON_BAR_W", 88)
|
||||
let barGap = envDouble("ICON_BAR_GAP", 62)
|
||||
let barBottom = envDouble("ICON_BAR_BOTTOM", 688)
|
||||
let barHeights: [CGFloat] = [142, 242, 342]
|
||||
let barsWidth = CGFloat(barHeights.count) * barWidth + CGFloat(barHeights.count - 1) * barGap
|
||||
let barsStartX = (side - barsWidth) / 2
|
||||
|
||||
for (index, height) in barHeights.enumerated() {
|
||||
let rect = CGRect(
|
||||
x: barsStartX + CGFloat(index) * (barWidth + barGap),
|
||||
y: barBottom - height,
|
||||
width: barWidth,
|
||||
height: height
|
||||
)
|
||||
let radius = barWidth * 0.30
|
||||
let path = roundedPath(rect, radius)
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.setShadow(offset: CGSize(width: 0, height: 5), blur: 16, color: rgb(255, 236, 176, 104))
|
||||
ctx.addPath(path)
|
||||
ctx.setFillColor(rgb(0, 0, 0))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
fillLinear(
|
||||
path,
|
||||
[
|
||||
(0.00, (5, 13, 30, 255)),
|
||||
(0.58, (9, 23, 50, 255)),
|
||||
(1.00, (17, 38, 74, 255)),
|
||||
] as [Stop],
|
||||
CGPoint(x: rect.minX, y: rect.minY),
|
||||
CGPoint(x: rect.minX, y: rect.maxY)
|
||||
)
|
||||
|
||||
directionalRim(
|
||||
path, rect, radius * 0.34,
|
||||
[
|
||||
(0.00, (0, 0, 0, 120)),
|
||||
(0.48, (0, 0, 0, 26)),
|
||||
(0.84, (255, 226, 152, 70)),
|
||||
(1.00, (255, 236, 176, 118)),
|
||||
] as [Stop]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Surface sheen
|
||||
|
||||
ctx.saveGState()
|
||||
ctx.addPath(rectPath(CGRect(x: 0, y: 0, width: side, height: side)))
|
||||
ctx.clip()
|
||||
ctx.move(to: CGPoint(x: -120, y: 430))
|
||||
ctx.addLine(to: CGPoint(x: 1140, y: 40))
|
||||
ctx.addLine(to: CGPoint(x: 1140, y: 214))
|
||||
ctx.addLine(to: CGPoint(x: -120, y: 604))
|
||||
ctx.closePath()
|
||||
ctx.setFillColor(rgb(255, 255, 255, 4))
|
||||
ctx.fillPath()
|
||||
ctx.restoreGState()
|
||||
|
||||
guard let image = ctx.makeImage() else {
|
||||
FileHandle.standardError.write("cannot finalize image\n".data(using: .utf8)!)
|
||||
exit(1)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1,6 +1,6 @@
|
||||
public enum AppMetadata {
|
||||
public static let name = "MultiSimAdmin"
|
||||
public static let bundleIdentifier = "fun.chick.multi-simadmin"
|
||||
public static let marketingVersion = "0.2.0"
|
||||
public static let buildVersion = "3"
|
||||
public static let marketingVersion = "0.2.1"
|
||||
public static let buildVersion = "6"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ final class DeviceViewModel: ObservableObject {
|
||||
} catch DeviceServiceError.unauthorized {
|
||||
errorMessage = "需要实例密码登录后才能读取设备数据。"
|
||||
} catch {
|
||||
errorMessage = "无法加载设备数据。"
|
||||
errorMessage = "节点无响应,请确认地址可访问、设备已开机且端口未被拦截。"
|
||||
}
|
||||
guard let probe else {
|
||||
connectionStatus = .offline
|
||||
|
||||
@@ -19,13 +19,17 @@ struct InstanceDetailView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,56 +55,85 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 读取失败且从未拿到数据时,整屏骨架没有意义,改成一屏可读的失败卡片。
|
||||
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)
|
||||
if let groupName {
|
||||
AIKeyValueRow("分组", value: groupName)
|
||||
}
|
||||
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: 14) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(model.instance.name)
|
||||
.font(.title3.weight(.bold))
|
||||
.kerning(-0.2)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(model.instance.origin.absoluteString)
|
||||
.font(.caption)
|
||||
// 导航栏已经显示实例名,卡片内不再重复,只留地址与状态。
|
||||
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)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Spacer(minLength: 8)
|
||||
statusChip()
|
||||
}
|
||||
|
||||
DetailMetricGrid(metrics: headerMetrics)
|
||||
DetailMetricGrid(metrics: gaugeMetrics)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text("数据模块")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
Spacer()
|
||||
Text(availableCount.map { "\($0)/\(moduleSections.count)" } ?? "读取中")
|
||||
.font(.caption.weight(.semibold))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(availableCount == moduleSections.count ? AITheme.success : AITheme.muted)
|
||||
}
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 9) {
|
||||
ForEach(stride(from: 0, to: moduleSections.count, by: 2).map {
|
||||
Array(moduleSections[$0..<min($0 + 2, moduleSections.count)])
|
||||
}, id: \.first?.id) { rowSections in
|
||||
GridRow {
|
||||
ForEach(rowSections, id: \.id) { section in
|
||||
moduleIndicator(
|
||||
section.title,
|
||||
isAvailable: section.values.isEmpty == false
|
||||
)
|
||||
}
|
||||
if rowSections.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DetailIdentityStrip(items: identitySummary, isLoading: model.isLoading)
|
||||
|
||||
moduleAvailability
|
||||
|
||||
if groupName != nil || !model.instance.tags.isEmpty {
|
||||
FlowLayout(spacing: 7) {
|
||||
@@ -111,11 +144,74 @@ struct InstanceDetailView: View {
|
||||
AITag(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?
|
||||
@@ -126,16 +222,12 @@ struct InstanceDetailView: View {
|
||||
var id: String { title }
|
||||
}
|
||||
|
||||
private var headerMetrics: [HeaderMetric] {
|
||||
let loading = model.snapshot == nil
|
||||
private var allHeaderMetrics: [HeaderMetric] {
|
||||
let loading = model.isLoading
|
||||
let phoneValue = stringValue(
|
||||
section: "sim",
|
||||
keys: ["phone_numbers", "phone_number"]
|
||||
)
|
||||
let firmwareValue = stringValue(
|
||||
section: "device",
|
||||
keys: ["revision", "firmware_version"]
|
||||
)
|
||||
let storageValue = storageSummary
|
||||
let uptimeValue = stringValue(
|
||||
section: "stats",
|
||||
@@ -169,55 +261,66 @@ struct InstanceDetailView: View {
|
||||
progress: loading ? nil : { max(0, min(1, $0 / 100)) },
|
||||
tint: loading ? nil : { resourceTint($0) }
|
||||
)
|
||||
let deviceValue = stringValue(section: "device", keys: ["model", "name", "device"])
|
||||
let carrierValue = stringValue(section: "network", keys: ["operator_name", "operator", "carrier"])
|
||||
let networkValue = stringValue(
|
||||
section: "network",
|
||||
keys: ["network_type", "technology_preference", "technology", "radio_mode"]
|
||||
)
|
||||
let ipValue = stringValue(section: "network", keys: ["ipv4", "ip_address", "ipv6"])
|
||||
|
||||
return [
|
||||
HeaderMetric(
|
||||
title: "连接状态",
|
||||
value: loading ? "--" : model.connectionStatus.label,
|
||||
systemImage: "dot.radiowaves.left.and.right",
|
||||
tint: loading ? AITheme.muted : statusColor(model.connectionStatus),
|
||||
progress: loading ? 0 : (model.connectionStatus == .online ? 1 : 0)
|
||||
),
|
||||
HeaderMetric(
|
||||
makeMetric(
|
||||
title: "号码",
|
||||
value: loading ? "--" : phoneValue ?? "无数据",
|
||||
value: loading ? nil : phoneValue ?? "无数据",
|
||||
systemImage: "simcard",
|
||||
tint: loading ? AITheme.muted : AITheme.ink,
|
||||
progress: loading ? 0 : (phoneValue == nil ? 0 : 1)
|
||||
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),
|
||||
HeaderMetric(
|
||||
makeMetric(
|
||||
title: "CPU",
|
||||
value: loading ? "--" : cpu?.value ?? "无数据",
|
||||
value: loading ? nil : cpu?.value ?? "无数据",
|
||||
systemImage: "cpu",
|
||||
tint: loading ? AITheme.muted : cpu?.tint ?? AITheme.muted,
|
||||
progress: loading ? 0 : cpu?.progress
|
||||
tint: loading ? nil : cpu?.tint ?? AITheme.muted,
|
||||
progress: loading ? nil : cpu?.progress
|
||||
),
|
||||
HeaderMetric(
|
||||
makeMetric(
|
||||
title: "内存",
|
||||
value: loading ? "--" : memory?.value ?? "无数据",
|
||||
value: loading ? nil : memory?.value ?? "无数据",
|
||||
systemImage: "memorychip",
|
||||
tint: loading ? AITheme.muted : memory?.tint ?? AITheme.muted,
|
||||
progress: loading ? 0 : memory?.progress
|
||||
tint: loading ? nil : memory?.tint ?? AITheme.muted,
|
||||
progress: loading ? nil : memory?.progress
|
||||
),
|
||||
makeMetric(title: "设备", value: loading ? nil : deviceValue, systemImage: "iphone.gen3", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "运营商", value: loading ? nil : carrierValue, systemImage: "antenna.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "网络", value: loading ? nil : networkValue, systemImage: "dot.radiowaves.left.and.right", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "IP", value: loading ? nil : ipValue, systemImage: "network", tint: loading ? nil : AITheme.ink),
|
||||
makeMetric(title: "固件", value: loading ? nil : firmwareValue, systemImage: "waveform.path.ecg", tint: loading ? nil : AITheme.ink),
|
||||
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?,
|
||||
@@ -389,43 +492,27 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, minHeight: 76, maxHeight: 76, alignment: .topLeading)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.aiInsetSurface()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// 统一两列栅格。此前首行放 4 个指标、其余两列,导致首行指标卡只有下方一半宽。
|
||||
private struct DetailMetricGrid: View {
|
||||
let metrics: [HeaderMetric]
|
||||
|
||||
private let primaryKeys: Set<String> = ["连接状态", "号码", "信号", "延迟"]
|
||||
private let valueKeys: Set<String> = [
|
||||
"设备", "运营商", "网络", "IP", "固件", "存储", "运行时间"
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
let primary = metrics.filter { primaryKeys.contains($0.title) }
|
||||
let values = metrics.filter { valueKeys.contains($0.title) }
|
||||
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
ForEach(
|
||||
stride(from: 0, to: metrics.count, by: 2).map {
|
||||
Array(metrics[$0..<min($0 + 2, metrics.count)])
|
||||
},
|
||||
id: \.first?.id
|
||||
) { rowMetrics in
|
||||
GridRow {
|
||||
ForEach(primary) { DetailHeaderMetric(metric: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 8) {
|
||||
ForEach(
|
||||
stride(from: 0, to: values.count, by: 2).map {
|
||||
Array(values[$0..<min($0 + 2, values.count)])
|
||||
},
|
||||
id: \.first?.id
|
||||
) { rowMetrics in
|
||||
GridRow {
|
||||
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
|
||||
if rowMetrics.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
ForEach(rowMetrics) { DetailHeaderMetric(metric: $0) }
|
||||
if rowMetrics.count == 1 {
|
||||
Color.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,6 +520,57 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 设备身份信息改为一行摘要,细节交给下方的分区卡片,避免头部堆成十几块同宽小卡。
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInsetSurface()
|
||||
.accessibilityLabel("设备信息读取中")
|
||||
} else if items.isEmpty == false {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(items, id: \.label) { item in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(item.label)
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(width: 52, alignment: .leading)
|
||||
Text(item.value)
|
||||
.font(.caption.weight(.medium))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInsetSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func moduleIndicator(_ title: String, isAvailable: Bool) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
@@ -452,8 +590,12 @@ struct InstanceDetailView: View {
|
||||
.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,
|
||||
@@ -462,19 +604,25 @@ struct InstanceDetailView: View {
|
||||
) -> CGSize {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var x: CGFloat = 0
|
||||
var height: 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
|
||||
height += size.height + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
x += size.width + spacing
|
||||
height = max(height, size.height)
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
}
|
||||
widestRow = max(widestRow, x - spacing)
|
||||
totalHeight += rowHeight
|
||||
|
||||
return CGSize(width: max(0, x - spacing), height: height)
|
||||
return CGSize(width: max(0, widestRow), height: max(0, totalHeight))
|
||||
}
|
||||
|
||||
func placeSubviews(
|
||||
@@ -491,7 +639,7 @@ struct InstanceDetailView: View {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x > bounds.minX, x + size.width > bounds.maxX {
|
||||
x = bounds.minX
|
||||
y += rowHeight + spacing
|
||||
y += rowHeight + rowGap
|
||||
rowHeight = 0
|
||||
}
|
||||
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
|
||||
@@ -544,16 +692,19 @@ struct InstanceDetailView: View {
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
AISectionTitle(section.title, systemImage: sectionIcon(section.id))
|
||||
Divider()
|
||||
.overlay(AITheme.line)
|
||||
ForEach(Array(sortedValues(section.values).enumerated()), id: \.element.key) { index, item in
|
||||
if index > 0 {
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.6))
|
||||
// 行与行只靠发丝线分隔,纵向节奏交给 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))
|
||||
}
|
||||
AIKeyValueRow(fieldLabel(item.key), value: display(item.key, item.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,7 +716,7 @@ struct InstanceDetailView: View {
|
||||
case "device": return "iphone.gen3"
|
||||
case "sim": return "simcard"
|
||||
case "network": return "antenna.radiowaves.left.and.right"
|
||||
case "stats": return "gauge.with.needle"
|
||||
case "stats": return "chart.bar.fill"
|
||||
case "cpu": return "cpu"
|
||||
case "connectivity": return "dot.radiowaves.left.and.right"
|
||||
default: return "list.bullet.rectangle"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,6 @@ import SwiftUI
|
||||
enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
case none
|
||||
case status
|
||||
case tags
|
||||
case groups
|
||||
|
||||
var id: String { rawValue }
|
||||
@@ -13,7 +12,6 @@ enum FleetGroupMode: String, CaseIterable, Identifiable {
|
||||
switch self {
|
||||
case .none: return "平铺"
|
||||
case .status: return "状态"
|
||||
case .tags: return "标签"
|
||||
case .groups: return "分组"
|
||||
}
|
||||
}
|
||||
@@ -184,12 +182,15 @@ final class FleetViewModel: ObservableObject {
|
||||
@Published var rows: [Row] = []
|
||||
@Published var searchText = ""
|
||||
@Published var statusFilter: ConnectionStatus?
|
||||
@Published var groupMode: FleetGroupMode = {
|
||||
let stored = UserDefaults.standard.string(forKey: "fleet.groupMode")
|
||||
return FleetGroupMode(rawValue: stored ?? "") ?? .groups
|
||||
}() {
|
||||
@Published var groupMode: FleetGroupMode = .groups {
|
||||
didSet {
|
||||
UserDefaults.standard.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
preferences.set(groupMode.rawValue, forKey: "fleet.groupMode")
|
||||
}
|
||||
}
|
||||
@Published var selectedTagFilters: Set<String> = [] {
|
||||
didSet {
|
||||
let values = Array(selectedTagFilters).sorted()
|
||||
preferences.set(values, forKey: "fleet.selectedTagFilters")
|
||||
}
|
||||
}
|
||||
@Published var selectedIDs: Set<UUID> = []
|
||||
@@ -206,19 +207,35 @@ final class FleetViewModel: ObservableObject {
|
||||
private let credentials: any CredentialStore
|
||||
private let deviceService: (any DeviceSummaryService)?
|
||||
private let groupStore: (any InstanceGroupStore)?
|
||||
private let preferences: UserDefaults
|
||||
|
||||
init(
|
||||
repository: any InstanceRepository,
|
||||
probe: ConnectionProbe,
|
||||
credentials: any CredentialStore,
|
||||
deviceService: (any DeviceSummaryService)? = nil,
|
||||
groupStore: (any InstanceGroupStore)? = nil
|
||||
groupStore: (any InstanceGroupStore)? = nil,
|
||||
preferences: UserDefaults = .standard
|
||||
) {
|
||||
self.repository = repository
|
||||
self.probe = probe
|
||||
self.credentials = credentials
|
||||
self.deviceService = deviceService
|
||||
self.groupStore = groupStore
|
||||
self.preferences = preferences
|
||||
// 旧版本的「按标签分组」已废弃,标签现在只作为筛选条件存在。
|
||||
let storedMode = preferences.string(forKey: "fleet.groupMode")
|
||||
groupMode = FleetGroupMode(rawValue: storedMode ?? "") ?? .groups
|
||||
selectedTagFilters = Set(preferences.stringArray(forKey: "fleet.selectedTagFilters") ?? [])
|
||||
}
|
||||
|
||||
/// 节点列表变化后,丢弃已经不存在任何实例的标签筛选,避免列表被隐藏条件清空。
|
||||
func pruneTagFilters() {
|
||||
guard selectedTagFilters.isEmpty == false else { return }
|
||||
let live = selectedTagFilters.filter { countForTagFilter($0) > 0 }
|
||||
if live != selectedTagFilters {
|
||||
selectedTagFilters = live
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
@@ -243,6 +260,7 @@ final class FleetViewModel: ObservableObject {
|
||||
} catch {
|
||||
errorMessage = "无法刷新节点列表"
|
||||
}
|
||||
pruneTagFilters()
|
||||
lastRefresh = Date()
|
||||
isLoading = false
|
||||
}
|
||||
@@ -400,6 +418,13 @@ final class FleetViewModel: ObservableObject {
|
||||
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,
|
||||
@@ -417,6 +442,47 @@ final class FleetViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
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 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()
|
||||
}
|
||||
|
||||
var groupedRows: [FleetGroup] {
|
||||
let rows = visibleRows.sorted {
|
||||
$0.instance.name.localizedCompare($1.instance.name) == .orderedAscending
|
||||
@@ -431,19 +497,6 @@ final class FleetViewModel: ObservableObject {
|
||||
guard !matched.isEmpty else { return nil }
|
||||
return FleetGroup(id: status.rawValue, title: status.label, rows: matched)
|
||||
}
|
||||
case .tags:
|
||||
let tags = Array(Set(rows.flatMap(\.instance.tags))).sorted()
|
||||
let grouped = tags.map { tag in
|
||||
FleetGroup(
|
||||
id: tag,
|
||||
title: tag,
|
||||
rows: rows.filter { $0.instance.tags.contains(tag) }
|
||||
)
|
||||
}
|
||||
let untagged = rows.filter { $0.instance.tags.isEmpty }
|
||||
return grouped + (untagged.isEmpty
|
||||
? []
|
||||
: [FleetGroup(id: "__untagged__", title: "未打标签", rows: untagged)])
|
||||
case .groups:
|
||||
var groupedRows: [UUID: [Row]] = [:]
|
||||
var ungroupedRows: [Row] = []
|
||||
|
||||
@@ -6,7 +6,8 @@ struct InstanceFormView: View {
|
||||
let instance: Instance?
|
||||
@State private var name = ""
|
||||
@State private var originText = ""
|
||||
@State private var tagsText = ""
|
||||
@State private var tags: [String] = []
|
||||
@State private var tagDraft = ""
|
||||
@State private var password = ""
|
||||
@State private var selectedGroupId: UUID?
|
||||
@State private var validationMessage: String?
|
||||
@@ -17,7 +18,7 @@ struct InstanceFormView: View {
|
||||
self.instance = instance
|
||||
_name = State(initialValue: instance?.name ?? "")
|
||||
_originText = State(initialValue: instance?.origin.absoluteString ?? "")
|
||||
_tagsText = State(initialValue: instance?.tags.joined(separator: ", ") ?? "")
|
||||
_tags = State(initialValue: instance?.tags ?? [])
|
||||
_selectedGroupId = State(initialValue: instance?.groupId)
|
||||
}
|
||||
|
||||
@@ -34,12 +35,12 @@ struct InstanceFormView: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
field("名称", text: $name, placeholder: "Home Node")
|
||||
field("地址", text: $originText, placeholder: "http://192.168.1.10:3000")
|
||||
field("标签", text: $tagsText, placeholder: "home, primary")
|
||||
tagEditor
|
||||
groupPicker
|
||||
field(
|
||||
"实例密码",
|
||||
text: $password,
|
||||
placeholder: instance == nil ? "可选,保存在本机沙盒" : "留空则不修改密码",
|
||||
placeholder: instance == nil ? "可选,仅保存在本机" : "留空则不修改密码",
|
||||
secure: true
|
||||
)
|
||||
}
|
||||
@@ -104,6 +105,14 @@ struct InstanceFormView: View {
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
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>,
|
||||
@@ -121,12 +130,12 @@ struct InstanceFormView: View {
|
||||
TextField(placeholder, text: text)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.accessibilityLabel(title)
|
||||
.submitLabel(title == "地址" ? .next : .done)
|
||||
.submitLabel(.done)
|
||||
#if os(iOS)
|
||||
.keyboardType(title == "地址" ? .URL : .default)
|
||||
.textInputAutocapitalization(title == "地址" || title == "标签" ? .never : nil)
|
||||
.textInputAutocapitalization(.never)
|
||||
#endif
|
||||
.autocorrectionDisabled(title == "地址" || title == "标签")
|
||||
.autocorrectionDisabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,11 +152,6 @@ struct InstanceFormView: View {
|
||||
isSaving = false
|
||||
return
|
||||
}
|
||||
let tags =
|
||||
tagsText
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let input = InstanceInput(
|
||||
name: normalizedName,
|
||||
origin: origin,
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
/// 批量归类:点一个分组就把目标实例移进去并立即生效,不再需要二次“保存”。
|
||||
struct InstanceGroupEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@ObservedObject var model: FleetViewModel
|
||||
let targetIDs: [UUID]
|
||||
let onSaved: () -> Void
|
||||
|
||||
@State private var selectedGroupID: UUID?
|
||||
@State private var isCreatingGroup = false
|
||||
@State private var newGroupName = ""
|
||||
@State private var isWorking = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var selectedCount: Int { targetIDs.count }
|
||||
private var targets: [UUID] {
|
||||
targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
|
||||
}
|
||||
|
||||
private var targetGroupIDs: [UUID?] {
|
||||
let owner = Dictionary(model.rows.map { ($0.id, $0.instance.groupId) }, uniquingKeysWith: { a, _ in a })
|
||||
return targets.compactMap { owner[$0] }
|
||||
}
|
||||
|
||||
/// 目标实例当前是否同属一个分组;nil 表示 mixed,不显示对勾。
|
||||
private var uniformGroup: UUID?? {
|
||||
guard let first = targetGroupIDs.first else { return nil }
|
||||
return targetGroupIDs.allSatisfy { $0 == first } ? .some(first) : nil
|
||||
}
|
||||
|
||||
private var trimmedName: String {
|
||||
newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -26,56 +43,63 @@ struct InstanceGroupEditorView: View {
|
||||
)
|
||||
}
|
||||
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle(
|
||||
"批量归类",
|
||||
systemImage: "folder.badge.gearshape",
|
||||
detail: selectedCount == 0 ? "全部实例" : "\(selectedCount) 个实例"
|
||||
)
|
||||
Text("选择一个分组后保存,所选实例会立即移入该分组。")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
AISectionTitle(
|
||||
"移动到",
|
||||
systemImage: "folder",
|
||||
detail: "\(targets.count) 台实例"
|
||||
)
|
||||
|
||||
if isCreatingGroup {
|
||||
AICard(padding: 16) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AIFieldLabel("新分组名称")
|
||||
TextField("例如:家里、外场、测试机", text: $newGroupName)
|
||||
.textFieldStyle(AIFieldStyle())
|
||||
.submitLabel(.done)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AICard(padding: 8) {
|
||||
VStack(spacing: 2) {
|
||||
groupRow(
|
||||
title: "未分组",
|
||||
subtitle: "移出当前分组",
|
||||
systemImage: "square.dashed",
|
||||
value: nil,
|
||||
isSelected: selectedGroupID == nil,
|
||||
selectionTrait: selectedGroupID == nil
|
||||
)
|
||||
.onSubmit { Task { await createAndAssign() } }
|
||||
AIActionRow(spacing: 8) {
|
||||
Button("取消") {
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking)
|
||||
|
||||
ForEach(model.groups) { group in
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.55))
|
||||
groupRow(
|
||||
title: group.name,
|
||||
subtitle: "\(instanceCount(group.id)) 个实例",
|
||||
systemImage: "folder",
|
||||
value: group.id,
|
||||
isSelected: selectedGroupID == group.id,
|
||||
selectionTrait: selectedGroupID == group.id
|
||||
)
|
||||
Button {
|
||||
Task { await createAndAssign() }
|
||||
} label: {
|
||||
Label("创建并移入", systemImage: "folder.badge.plus")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(trimmedName.isEmpty || isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AICard(padding: 8) {
|
||||
VStack(spacing: 2) {
|
||||
groupRow(
|
||||
title: "未分组",
|
||||
subtitle: "不归属任何分组",
|
||||
systemImage: "square.dashed",
|
||||
groupID: nil
|
||||
)
|
||||
|
||||
ForEach(model.groups) { group in
|
||||
Divider()
|
||||
.overlay(AITheme.line.opacity(0.55))
|
||||
groupRow(
|
||||
title: group.name,
|
||||
subtitle: "\(instanceCount(group.id)) 台在此",
|
||||
systemImage: "folder",
|
||||
groupID: group.id
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(isWorking ? 0.55 : 1)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
@@ -89,28 +113,13 @@ struct InstanceGroupEditorView: View {
|
||||
Button("取消") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button("新建") {
|
||||
selectedGroupID = nil
|
||||
isCreatingGroup = true
|
||||
AIIconButton("新建分组", systemImage: "plus") {
|
||||
errorMessage = nil
|
||||
isCreatingGroup = true
|
||||
}
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
.aiBottomActionBar {
|
||||
AIActionRow {
|
||||
Button {
|
||||
Task { await save() }
|
||||
} label: {
|
||||
Label(
|
||||
selectedGroupID == nil ? "移出分组" : "移入分组",
|
||||
systemImage: "tray.and.arrow.down"
|
||||
)
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true))
|
||||
.disabled(isWorking || (selectedGroupID == nil && !isCreatingGroup))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,23 +127,27 @@ struct InstanceGroupEditorView: View {
|
||||
title: String,
|
||||
subtitle: String,
|
||||
systemImage: String,
|
||||
value: UUID?,
|
||||
isSelected: Bool,
|
||||
selectionTrait: Bool
|
||||
groupID: UUID?
|
||||
) -> some View {
|
||||
Button {
|
||||
selectedGroupID = value
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
errorMessage = nil
|
||||
guard uniformGroup != groupID else {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
Task { await apply(groupID: groupID) }
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
.foregroundStyle(isCurrent(groupID) ? AITheme.primary : AITheme.muted)
|
||||
.frame(width: 32, height: 32)
|
||||
.background((isSelected ? AITheme.primary : AITheme.muted).opacity(0.10))
|
||||
.background((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.overlay {
|
||||
// 深色下 10% 色洗几乎看不出边界,补一圈同色淡描边保住方块轮廓。
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder((isCurrent(groupID) ? AITheme.primary : AITheme.muted).opacity(0.20))
|
||||
}
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -150,56 +163,64 @@ struct InstanceGroupEditorView: View {
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
Image(
|
||||
systemName: isSelected
|
||||
? "checkmark.circle.fill"
|
||||
: "circle.dashed"
|
||||
)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(isSelected ? AITheme.primary : AITheme.muted)
|
||||
Group {
|
||||
if isWorking {
|
||||
EmptyView()
|
||||
} else if isCurrent(groupID) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(AITheme.primary)
|
||||
}
|
||||
}
|
||||
.frame(width: 20, alignment: .trailing)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.frame(minHeight: 54)
|
||||
.background(isSelected ? AITheme.primary.opacity(0.08) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(title),\(subtitle)")
|
||||
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : [.isButton])
|
||||
.accessibilityLabel(isCurrent(groupID) ? "\(title),当前分组" : "移动到\(title)")
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isWorking)
|
||||
}
|
||||
|
||||
private func isCurrent(_ groupID: UUID?) -> Bool {
|
||||
uniformGroup == groupID && targetGroupIDs.isEmpty == false
|
||||
}
|
||||
|
||||
private func instanceCount(_ id: UUID) -> Int {
|
||||
model.rows.filter { $0.instance.groupId == id }.count
|
||||
}
|
||||
|
||||
private func save() async {
|
||||
private func apply(groupID: UUID?) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
if isCreatingGroup {
|
||||
let name = newGroupName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let group = try await model.createGroup(named: name)
|
||||
selectedGroupID = group.id
|
||||
isCreatingGroup = false
|
||||
newGroupName = ""
|
||||
}
|
||||
|
||||
try await model.assign(
|
||||
groupID: selectedGroupID,
|
||||
to: targetIDs.isEmpty ? model.visibleRows.map(\.id) : targetIDs
|
||||
)
|
||||
try await model.assign(groupID: groupID, to: targets)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = isCreatingGroup
|
||||
? "创建分组失败,名称不能为空且不能重复。"
|
||||
: "保存分组失败,请检查节点后重试。"
|
||||
errorMessage = "分组保存失败,请重试。"
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
|
||||
private func createAndAssign() async {
|
||||
guard !isWorking, trimmedName.isEmpty == false else { return }
|
||||
isWorking = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let group = try await model.createGroup(named: trimmedName)
|
||||
try await model.assign(groupID: group.id, to: targets)
|
||||
onSaved()
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = "创建分组失败,名称不能为空且不能重复。"
|
||||
isWorking = false
|
||||
}
|
||||
isWorking = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ struct InstanceGroupManagerView: View {
|
||||
@State private var editingName = ""
|
||||
@State private var validationMessage: String?
|
||||
@State private var isWorking = false
|
||||
@State private var pendingDelete: InstanceGroup?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
@@ -62,24 +63,16 @@ struct InstanceGroupManagerView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
AISymbolAction("重命名分组 \(group.name)", systemImage: "pencil", tint: AITheme.muted) {
|
||||
editingGroup = group
|
||||
editingName = group.name
|
||||
validationMessage = nil
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .secondary, size: .toolbarInline, fixedWidth: 44))
|
||||
|
||||
Button {
|
||||
Task {
|
||||
await delete(group)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
AISymbolAction("删除分组 \(group.name)", systemImage: "trash", tint: AITheme.danger) {
|
||||
pendingDelete = group
|
||||
}
|
||||
.buttonStyle(AIButtonStyle(kind: .danger, size: .toolbarInline, fixedWidth: 44))
|
||||
.disabled(instanceCount(group.id) > 0 || isWorking)
|
||||
.disabled(isWorking)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +117,23 @@ struct InstanceGroupManagerView: View {
|
||||
} message: {
|
||||
Text("分组名称最多 100 个字符。")
|
||||
}
|
||||
.confirmationDialog(
|
||||
pendingDelete == nil
|
||||
? "删除分组?"
|
||||
: "删除「\(pendingDelete!.name)」?该分组下的 \(instanceCount(pendingDelete!.id)) 个实例会回到未分组。",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let group = pendingDelete {
|
||||
Button("删除分组", role: .destructive) {
|
||||
Task { await delete(group) }
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) {}
|
||||
}
|
||||
.task {
|
||||
await model.loadGroups()
|
||||
}
|
||||
@@ -163,6 +173,7 @@ struct InstanceGroupManagerView: View {
|
||||
private func delete(_ group: InstanceGroup) async {
|
||||
guard !isWorking else { return }
|
||||
isWorking = true
|
||||
pendingDelete = nil
|
||||
do {
|
||||
try await model.deleteGroup(group.id)
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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: 15, weight: .semibold))
|
||||
.frame(width: 44, height: 44)
|
||||
.foregroundStyle(canAdd ? AITheme.primary : AITheme.muted)
|
||||
.background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line)
|
||||
}
|
||||
.contentShape(RoundedRectangle(cornerRadius: 14, 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()
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,38 @@ struct LogsView: View {
|
||||
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 let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.danger, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.snapshot == nil && model.isLoading {
|
||||
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,
|
||||
@@ -39,7 +60,14 @@ struct LogsView: View {
|
||||
]
|
||||
)
|
||||
|
||||
filterBar
|
||||
if hasRowsForTab {
|
||||
filterBar
|
||||
}
|
||||
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
|
||||
switch tab {
|
||||
case .runtime:
|
||||
@@ -58,11 +86,7 @@ struct LogsView: View {
|
||||
.navigationTitle("日志中心")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
AIToolbarButton(
|
||||
"刷新",
|
||||
systemImage: "arrow.clockwise",
|
||||
kind: .primary
|
||||
) {
|
||||
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
@@ -90,67 +114,74 @@ struct LogsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 三块全 0 的读数和下方的空态说的是同一件事,没有记录时整段不出现。
|
||||
private var stats: some View {
|
||||
LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 12
|
||||
) {
|
||||
AIStatCard(title: "日志", value: "\(model.snapshot?.runtime.count ?? 0)", tint: AITheme.primary)
|
||||
AIStatCard(title: "在线", value: "\(onlineCount)", tint: AITheme.success)
|
||||
AIStatCard(title: "离线", value: "\(model.offlineCount)", tint: AITheme.danger)
|
||||
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 {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
AISearchField("搜索日志、错误码或实例", text: $model.searchText)
|
||||
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))
|
||||
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()
|
||||
|
||||
Group {
|
||||
if tab == .runtime {
|
||||
HStack(spacing: 10) {
|
||||
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()
|
||||
|
||||
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()
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.aiSelectSurface(compact: true)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: tab)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: tab)
|
||||
}
|
||||
|
||||
private var runtimeSection: some View {
|
||||
@@ -158,7 +189,7 @@ struct LogsView: View {
|
||||
AISectionTitle("统一时间线", systemImage: "clock", detail: "\(model.filteredRuntime.count) 条")
|
||||
if model.filteredRuntime.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "下拉刷新后会同步实例运行记录。")
|
||||
AIEmptyState(systemImage: "clock", title: "暂无运行日志", message: "所有节点都没有返回运行记录。")
|
||||
}
|
||||
} else {
|
||||
ForEach(model.filteredRuntime) { entry in
|
||||
@@ -255,10 +286,6 @@ struct LogsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var onlineCount: Int {
|
||||
(model.snapshot?.diagnostics.filter { $0.state == .online }.count) ?? 0
|
||||
}
|
||||
|
||||
private func levelTint(_ level: RuntimeLogLevel) -> Color {
|
||||
switch level {
|
||||
case .info: return AITheme.success
|
||||
|
||||
@@ -15,7 +15,8 @@ public enum SMSDirection: String, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public struct SMSMessage: Equatable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
|
||||
public let upstreamID: String
|
||||
public let instanceID: UUID
|
||||
public let instanceName: String
|
||||
public let phoneNumber: String
|
||||
@@ -23,6 +24,48 @@ public struct SMSMessage: Equatable, Identifiable, Sendable {
|
||||
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 {
|
||||
|
||||
@@ -133,7 +133,7 @@ public struct MessageService: Sendable {
|
||||
: directionText == "outgoing" ? .outgoing
|
||||
: .unknown
|
||||
return SMSMessage(
|
||||
id: id,
|
||||
upstreamID: id,
|
||||
instanceID: instance.id,
|
||||
instanceName: instance.name,
|
||||
phoneNumber: value["phone_number"] as? String ?? "",
|
||||
|
||||
@@ -12,24 +12,35 @@ struct MessagesView: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
filterBar
|
||||
if let errorMessage = model.errorMessage {
|
||||
AIStatusBanner(
|
||||
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
if model.isLoading && model.filteredRows.isEmpty {
|
||||
AILoadingCard("正在同步消息数据")
|
||||
}
|
||||
if model.filteredRows.isEmpty && !model.isLoading {
|
||||
} else if model.filteredRows.isEmpty {
|
||||
AICard {
|
||||
AIEmptyState(
|
||||
systemImage: "message",
|
||||
title: "暂无消息",
|
||||
message: "刷新后从实例同步短信。"
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
ForEach(model.filteredRows) { message in
|
||||
messageCard(message)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
@@ -45,6 +56,14 @@ struct MessagesView: View {
|
||||
}
|
||||
.background(AITheme.canvas.ignoresSafeArea())
|
||||
.navigationTitle("消息中心")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"删除这条消息?操作不可恢复。",
|
||||
isPresented: Binding(
|
||||
@@ -71,68 +90,83 @@ struct MessagesView: View {
|
||||
}
|
||||
|
||||
private var filterBar: some View {
|
||||
AICard {
|
||||
VStack(spacing: 10) {
|
||||
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))
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
AISegmentedControl(
|
||||
"消息方向",
|
||||
selection: $model.direction,
|
||||
options: MessagesViewModel.DirectionFilter.allCases.map {
|
||||
(value: $0, title: $0.rawValue)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.aiSelectSurface()
|
||||
)
|
||||
|
||||
AISearchField("搜索号码或内容", text: $model.searchText)
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
AICard(padding: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
|
||||
.font(.headline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.layoutPriority(1)
|
||||
Spacer()
|
||||
AITag(
|
||||
message.direction.label,
|
||||
tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
|
||||
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)
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text(message.instanceName)
|
||||
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)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
Button {
|
||||
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger) {
|
||||
pendingDeleteMessage = message
|
||||
} label: {
|
||||
Label("删除", systemImage: "trash")
|
||||
}
|
||||
.buttonStyle(
|
||||
AIButtonStyle(
|
||||
kind: .danger,
|
||||
size: .compact,
|
||||
fixedWidth: AITheme.listActionButtonWidth
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +61,9 @@ final class MessagesViewModel: ObservableObject {
|
||||
let page = try await service.messages(loaded, limit: 100)
|
||||
rows = page.messages
|
||||
} catch MessageServiceError.unauthorized {
|
||||
errorMessage = "有实例需要登录,未获取受保护消息。"
|
||||
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
|
||||
} catch {
|
||||
errorMessage = "无法加载消息。"
|
||||
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
@@ -80,7 +80,7 @@ final class MessagesViewModel: ObservableObject {
|
||||
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 && $0.instanceID == message.instanceID }
|
||||
rows.removeAll { $0.id == message.id }
|
||||
} catch {
|
||||
errorMessage = "删除消息失败。"
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ struct SettingsView: View {
|
||||
AIStatusBanner(notice, tint: AITheme.success, systemImage: "checkmark.circle")
|
||||
}
|
||||
|
||||
runtimeCard
|
||||
themeCard
|
||||
runtimeCard
|
||||
storageCard
|
||||
securityCard
|
||||
dangerZoneCard
|
||||
@@ -33,11 +33,7 @@ struct SettingsView: View {
|
||||
.navigationTitle("设置")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
AIToolbarButton(
|
||||
"刷新",
|
||||
systemImage: "arrow.clockwise",
|
||||
kind: .primary
|
||||
) {
|
||||
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
@@ -67,7 +63,7 @@ struct SettingsView: View {
|
||||
AISectionTitle("应用信息", systemImage: "info.circle")
|
||||
if let runtime = model.runtime {
|
||||
AIKeyValueRow("版本", value: "\(runtime.marketingVersion) (\(runtime.buildVersion))")
|
||||
AIKeyValueRow("设备", value: runtime.deviceModel)
|
||||
AIKeyValueRow("系统", value: runtime.systemVersion)
|
||||
} else {
|
||||
Text("正在读取应用信息…")
|
||||
.font(.subheadline)
|
||||
@@ -80,19 +76,51 @@ struct SettingsView: View {
|
||||
private var themeCard: some View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("外观", systemImage: "circle.lefthalf.filled", detail: themeStore.mode.label)
|
||||
AISegmentedControl(
|
||||
"主题模式",
|
||||
selection: Binding(
|
||||
get: { themeStore.mode },
|
||||
set: { themeStore.set($0) }
|
||||
),
|
||||
options: ThemeMode.allCases.map { (value: $0, title: $0.label) }
|
||||
)
|
||||
// 三个选项块本身已经标出当前模式,标题右侧再重复一次是冗余信息。
|
||||
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.primary : AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
Text(mode.label)
|
||||
.font(.caption.weight(isSelected ? .semibold : .medium))
|
||||
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 68)
|
||||
.background(isSelected ? AITheme.primary.opacity(0.10) : AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(isSelected ? AITheme.primary.opacity(0.42) : 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 {
|
||||
@@ -107,8 +135,7 @@ struct SettingsView: View {
|
||||
.foregroundStyle(AITheme.muted)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
AIKeyValueRow("数据位置", value: "仅保存在本机设备")
|
||||
AIKeyValueRow("当前占用", value: model.storageLabel)
|
||||
AIKeyValueRow("日志记录", value: model.storageLabel)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -120,14 +147,21 @@ struct SettingsView: View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("隐私与数据", systemImage: "lock.shield")
|
||||
AIKeyValueRow("节点信息", value: "仅保存在本机设备")
|
||||
AIKeyValueRow("实例密码", value: "保存在本地数据文件中")
|
||||
AIKeyValueRow("登录会话", value: "应用关闭后自动清除")
|
||||
AIStatusBanner(
|
||||
"节点地址、实例密码和运行数据都保存在本机。应用只连接你添加的节点,登录会话关闭应用后即被清除。",
|
||||
tint: AITheme.primary,
|
||||
systemImage: "shield.checkered"
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,12 +170,35 @@ struct SettingsView: View {
|
||||
AICard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
AISectionTitle("数据清理", systemImage: "trash.square", detail: "不可恢复")
|
||||
// 整块实心红是这页最大的色块,会把"读设置"的视线抢走;退成一行危险色入口。
|
||||
Button {
|
||||
showsClearDialog = true
|
||||
} label: {
|
||||
Label("清除运行日志", systemImage: "trash")
|
||||
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(AIButtonStyle(kind: .danger, size: .regular, fillsWidth: true))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
struct SettingsRuntime: Equatable {
|
||||
let appName: String
|
||||
let marketingVersion: String
|
||||
let buildVersion: String
|
||||
let platform: String
|
||||
let deviceModel: String
|
||||
let systemVersion: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -32,7 +34,7 @@ final class SettingsViewModel: ObservableObject {
|
||||
marketingVersion: AppMetadata.marketingVersion,
|
||||
buildVersion: AppMetadata.buildVersion,
|
||||
platform: "iOS",
|
||||
deviceModel: Self.deviceModel()
|
||||
systemVersion: Self.systemVersion()
|
||||
)
|
||||
await loadStorageLabel()
|
||||
|
||||
@@ -64,14 +66,14 @@ final class SettingsViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static func deviceModel() -> String {
|
||||
var systemInfo = utsname()
|
||||
guard uname(&systemInfo) == 0 else { return "未知设备" }
|
||||
let bytes = withUnsafeBytes(of: systemInfo.machine) { Data($0) }
|
||||
let model = String(
|
||||
decoding: bytes.prefix { $0 != 0 },
|
||||
as: UTF8.self
|
||||
)
|
||||
return model.isEmpty ? "未知设备" : model
|
||||
/// 只报对用户有意义的系统版本,不暴露机器标识之类的原始信息。
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,21 @@ struct MultiSimAdminApp: App {
|
||||
|
||||
init() {
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
var initial: AppTab = .fleet
|
||||
if arguments.contains("--tab-messages") {
|
||||
_tab = State(initialValue: .messages)
|
||||
initial = .messages
|
||||
} else if arguments.contains("--tab-settings") {
|
||||
_tab = State(initialValue: .settings)
|
||||
} else {
|
||||
_tab = State(initialValue: .fleet)
|
||||
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 {
|
||||
@@ -26,6 +34,12 @@ struct MultiSimAdminApp: App {
|
||||
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) {
|
||||
@@ -57,8 +71,40 @@ 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, String?)?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let (instance, groupName) = target {
|
||||
InstanceDetailView(instance: instance, groupName: groupName)
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
let repository = FileInstanceRepository(location: DefaultContainer.documentFileURL("instances.json"))
|
||||
let groups = FileInstanceGroupStore(location: DefaultContainer.documentFileURL("instance-groups.json"))
|
||||
guard let listed = try? await repository.list(InstanceQuery()), listed.isEmpty == false else { return }
|
||||
// 截图验证优先看可达节点(3000),否则详情页全是空态,看不出真实排版。
|
||||
let instance =
|
||||
listed.first { $0.origin.port == 3000 } ?? listed[0]
|
||||
let name = (try? await groups.list())?.first { $0.id == instance.groupId }?.name
|
||||
target = (instance, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct AITabBar: View {
|
||||
@Binding var selection: AppTab
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 992 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -9,8 +9,8 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundleName</key><string>MultiSimAdmin</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.2.0</string>
|
||||
<key>CFBundleVersion</key><string>3</string>
|
||||
<key>CFBundleShortVersionString</key><string>0.2.1</string>
|
||||
<key>CFBundleVersion</key><string>6</string>
|
||||
<key>LSRequiresIPhoneOS</key><true/>
|
||||
<key>MinimumOSVersion</key><string>16.0</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -89,15 +89,9 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
.frame(maxWidth: fillsWidth ? .infinity : nil)
|
||||
.frame(width: fixedWidth)
|
||||
.background(
|
||||
kind == .primary && isEnabled && size != .toolbarInline
|
||||
? AnyShapeStyle(
|
||||
LinearGradient(
|
||||
colors: [AITheme.primaryTop, AITheme.primaryBottom],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
: AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled))
|
||||
// 主色面用纯色。之前的 primaryTop/primaryBottom 竖向渐变在系统里会被合成成灰紫,
|
||||
// 实测 #3B82F6 掉到 #6B81CA,整片按钮发脏。
|
||||
AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled))
|
||||
)
|
||||
.shadow(
|
||||
color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10),
|
||||
@@ -107,13 +101,13 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
)
|
||||
.clipShape(
|
||||
RoundedRectangle(
|
||||
cornerRadius: size == .toolbarInline ? AITheme.toolbarButtonHeight / 2 : AITheme.buttonRadius,
|
||||
cornerRadius: cornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(
|
||||
cornerRadius: size == .toolbarInline ? AITheme.toolbarButtonHeight / 2 : AITheme.buttonRadius,
|
||||
cornerRadius: cornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.strokeBorder(
|
||||
@@ -126,6 +120,15 @@ public struct AIButtonStyle: ButtonStyle {
|
||||
.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
|
||||
|
||||
@@ -25,7 +25,7 @@ public struct AICard<Content: View>: View {
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
||||
.shadow(color: AITheme.cardShadow, radius: 14, x: 0, y: 6)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
@@ -61,12 +61,14 @@ public struct AITag: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(minHeight: 30)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
// 与节点卡片上的标签胶囊同一形状,同一高度;深色底上 10% 色雾几乎看不见,
|
||||
// 所以描边权重必须够,不能让标签在暗色主题里消失。
|
||||
.frame(height: 28)
|
||||
.background(tint.opacity(0.14))
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.16))
|
||||
Capsule(style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.30))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,7 +129,6 @@ public struct AISectionTitle: View {
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.font(.headline.weight(.semibold))
|
||||
.kerning(-0.1)
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -158,6 +159,41 @@ public struct AIFieldLabel: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -180,33 +216,36 @@ public struct AIStatCard: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
AICard(padding: 12) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, 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))
|
||||
.foregroundStyle(value == "0" || value.hasPrefix("0/") ? AITheme.muted : tint)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// 卡内不再叠一张卡:双层阴影 + 双层描边是浅色发灰的主因。
|
||||
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)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,11 +286,34 @@ 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 {
|
||||
@@ -271,6 +333,13 @@ public struct AIEmptyState: View {
|
||||
.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)
|
||||
@@ -375,7 +444,7 @@ public struct AILoadingCard: View {
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.07), radius: 18, x: 0, y: 8)
|
||||
.shadow(color: AITheme.brand.opacity(0.08), radius: 20, x: 0, y: 10)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.cardRadius, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
@@ -460,11 +529,13 @@ 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) {
|
||||
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 {
|
||||
@@ -476,7 +547,12 @@ public struct AIKeyValueRow: View {
|
||||
.layoutPriority(1)
|
||||
Spacer(minLength: 12)
|
||||
Text(value)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(
|
||||
monospaced
|
||||
? Font.system(.subheadline, design: .monospaced, weight: .medium)
|
||||
: Font.subheadline.weight(.medium)
|
||||
)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(valueTint)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
@@ -505,7 +581,7 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(options, id: \.value) { option in
|
||||
let isSelected = selection == option.value
|
||||
|
||||
@@ -513,22 +589,16 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
selection = option.value
|
||||
} label: {
|
||||
Text(option.title)
|
||||
.font(.body.weight(isSelected ? .semibold : .medium))
|
||||
.font(.subheadline.weight(isSelected ? .semibold : .medium))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.foregroundStyle(isSelected ? AITheme.onPrimary : AITheme.muted)
|
||||
.foregroundStyle(isSelected ? AITheme.ink : AITheme.muted)
|
||||
.frame(maxWidth: .infinity, minHeight: AITheme.segmentedControlHeight)
|
||||
// 分段控件是"视图切换"而不是主操作:抬起的中性块,避免和标签筛选抢注意力。
|
||||
.background {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [AITheme.primaryTop, AITheme.primaryBottom],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
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)
|
||||
@@ -537,12 +607,12 @@ public struct AISegmentedControl<Value: Hashable>: View {
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(4)
|
||||
.background(AITheme.control)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.padding(3)
|
||||
.background(AITheme.segmentTrack)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.strokeBorder(AITheme.line)
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(AITheme.tileLine)
|
||||
}
|
||||
.animation(.easeOut(duration: 0.16), value: selection)
|
||||
.accessibilityElement(children: .contain)
|
||||
@@ -567,15 +637,18 @@ 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
|
||||
}
|
||||
|
||||
@@ -607,7 +680,7 @@ public struct AISearchField: View {
|
||||
.accessibilityLabel("清除搜索")
|
||||
}
|
||||
}
|
||||
.aiInputSurface(minHeight: AITheme.buttonHeight, isFocused: isFocused)
|
||||
.aiInputSurface(minHeight: minHeight, isFocused: isFocused)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,14 +724,14 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
func aiSelectSurface() -> some View {
|
||||
func aiSelectSurface(compact: Bool = false) -> some View {
|
||||
self
|
||||
.tint(AITheme.ink)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold))
|
||||
.foregroundStyle(AITheme.ink)
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.aiInputSurface()
|
||||
.aiInputSurface(minHeight: compact ? AITheme.compactControlHeight : AITheme.inputHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,3 +800,45 @@ public struct AIIconButton: View {
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
/// 列表行内的轻量图标动作:同色淡底小方块,触区仍补足 44pt。
|
||||
/// 行内操作用实心药丸会把一行读成"按钮墙",这里让颜色只表达危险程度。
|
||||
public struct AISymbolAction: View {
|
||||
private let title: String
|
||||
private let systemImage: String
|
||||
private let tint: Color
|
||||
private let action: () -> Void
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
systemImage: String,
|
||||
tint: Color = AITheme.muted,
|
||||
action: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.systemImage = systemImage
|
||||
self.tint = tint
|
||||
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(tint.opacity(0.10))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)
|
||||
.strokeBorder(tint.opacity(0.18))
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,41 +28,50 @@ private enum AIPalette {
|
||||
dynamicColor(light: light, dark: dark)
|
||||
}
|
||||
|
||||
static let canvas = color(light: rgba(245, 246, 248, 1), dark: rgba(6, 7, 9, 1))
|
||||
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(18, 20, 24, 1))
|
||||
static let surfaceRaised = color(light: rgba(251, 252, 253, 1), dark: rgba(27, 30, 35, 1))
|
||||
static let control = color(light: rgba(243, 245, 247, 1), dark: rgba(26, 29, 34, 1))
|
||||
static let chrome = color(light: rgba(255, 255, 255, 0.82), dark: rgba(11, 12, 15, 0.82))
|
||||
static let ink = color(light: rgba(15, 18, 22, 1), dark: rgba(243, 245, 247, 1))
|
||||
static let muted = color(light: rgba(98, 107, 119, 1), dark: rgba(151, 160, 172, 1))
|
||||
static let line = color(light: rgba(227, 230, 234, 1), dark: rgba(255, 255, 255, 0.09))
|
||||
static let brand = color(light: rgba(13, 15, 18, 1), dark: rgba(8, 9, 11, 1))
|
||||
static let primary = color(light: rgba(17, 20, 25, 1), dark: rgba(220, 178, 111, 1))
|
||||
static let primaryTop = color(light: rgba(43, 49, 58, 1), dark: rgba(240, 209, 154, 1))
|
||||
static let primaryBottom = color(light: rgba(15, 18, 22, 1), dark: rgba(198, 154, 92, 1))
|
||||
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 17, 1))
|
||||
static let danger = color(light: rgba(190, 65, 57, 1), dark: rgba(242, 124, 113, 1))
|
||||
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(22, 9, 8, 1))
|
||||
static let warning = color(light: rgba(170, 111, 23, 1), dark: rgba(226, 166, 65, 1))
|
||||
static let success = color(light: rgba(50, 150, 101, 1), dark: rgba(103, 198, 148, 1))
|
||||
// 浅色:中性冷灰底 + 纯白卡 + 极浅内嵌面,避免"灰蓝蒙层"把层级糊成一团。
|
||||
static let canvas = color(light: rgba(242, 244, 248, 1), dark: rgba(7, 10, 16, 1))
|
||||
static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(20, 26, 36, 1))
|
||||
static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(26, 35, 49, 1))
|
||||
static let control = color(light: rgba(237, 241, 247, 1), dark: rgba(34, 44, 60, 1))
|
||||
static let tile = color(light: rgba(247, 249, 252, 1), dark: rgba(255, 255, 255, 0.05))
|
||||
static let tileLine = color(light: rgba(233, 239, 247, 1), dark: rgba(255, 255, 255, 0.08))
|
||||
static let chrome = color(light: rgba(255, 255, 255, 0.90), dark: rgba(12, 17, 25, 0.86))
|
||||
static let ink = color(light: rgba(15, 23, 41, 1), dark: rgba(241, 245, 249, 1))
|
||||
static let muted = color(light: rgba(92, 106, 128, 1), dark: rgba(154, 167, 184, 1))
|
||||
static let faint = color(light: rgba(141, 152, 170, 1), dark: rgba(112, 127, 148, 1))
|
||||
static let line = color(light: rgba(224, 231, 240, 1), dark: rgba(255, 255, 255, 0.10))
|
||||
static let brand = color(light: rgba(11, 27, 51, 1), dark: rgba(7, 10, 16, 1))
|
||||
static let primary = color(light: rgba(18, 96, 214, 1), dark: rgba(96, 165, 250, 1))
|
||||
static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(5, 22, 43, 1))
|
||||
static let danger = color(light: rgba(199, 44, 44, 1), dark: rgba(248, 113, 113, 1))
|
||||
static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 7, 7, 1))
|
||||
static let warning = color(light: rgba(176, 84, 12, 1), dark: rgba(245, 158, 11, 1))
|
||||
static let success = color(light: rgba(16, 124, 90, 1), dark: rgba(74, 222, 128, 1))
|
||||
static let segmentTrack = color(light: rgba(236, 240, 246, 1), dark: rgba(12, 17, 25, 1))
|
||||
static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(48, 61, 81, 1))
|
||||
static let cardShadow = color(light: rgba(15, 30, 60, 0.07), dark: rgba(0, 0, 0, 0.55))
|
||||
#elseif canImport(AppKit)
|
||||
static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973)
|
||||
static let canvas = Color(red: 0.914, green: 0.933, blue: 0.961)
|
||||
static let surface = Color.white
|
||||
static let control = Color(red: 0.969, green: 0.976, blue: 0.984)
|
||||
static let ink = Color(red: 0.110, green: 0.153, blue: 0.220)
|
||||
static let muted = Color(red: 0.435, green: 0.486, blue: 0.561)
|
||||
static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
|
||||
static let brand = Color(red: 0.075, green: 0.118, blue: 0.188)
|
||||
static let surfaceRaised = Color(red: 0.984, green: 0.988, blue: 0.992)
|
||||
static let chrome = Color.white.opacity(0.82)
|
||||
static let control = Color(red: 0.941, green: 0.957, blue: 0.980)
|
||||
static let ink = Color(red: 0.035, green: 0.086, blue: 0.169)
|
||||
static let muted = Color(red: 0.275, green: 0.341, blue: 0.435)
|
||||
static let line = Color(red: 0.827, green: 0.867, blue: 0.918)
|
||||
static let brand = Color(red: 0.035, green: 0.086, blue: 0.169)
|
||||
static let 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.067, green: 0.078, blue: 0.098)
|
||||
static let primaryTop = Color(red: 0.17, green: 0.19, blue: 0.23)
|
||||
static let primaryBottom = Color(red: 0.06, green: 0.07, blue: 0.09)
|
||||
static let danger = Color(red: 0.745, green: 0.255, blue: 0.224)
|
||||
static let warning = Color(red: 0.667, green: 0.435, blue: 0.09)
|
||||
static let success = Color(red: 0.196, green: 0.588, blue: 0.396)
|
||||
static let primary = Color(red: 0.145, green: 0.388, blue: 0.922)
|
||||
static let danger = Color(red: 0.784, green: 0.118, blue: 0.118)
|
||||
static let warning = Color(red: 0.706, green: 0.325, blue: 0.035)
|
||||
static let success = Color(red: 0.071, green: 0.502, blue: 0.361)
|
||||
static let tile = Color(red: 0.969, green: 0.976, blue: 0.988)
|
||||
static let tileLine = Color(red: 0.914, green: 0.937, blue: 0.969)
|
||||
static let faint = Color(red: 0.553, green: 0.596, blue: 0.667)
|
||||
static let segmentTrack = Color(red: 0.925, green: 0.941, blue: 0.965)
|
||||
static let segmentSelected = Color.white
|
||||
static let cardShadow = Color(red: 0.059, green: 0.118, blue: 0.235).opacity(0.07)
|
||||
#else
|
||||
private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color {
|
||||
Color(red: red / 255, green: green / 255, blue: blue / 255)
|
||||
@@ -80,11 +89,15 @@ private enum AIPalette {
|
||||
static let onPrimary = color(255, 255, 255)
|
||||
static let onDanger = color(255, 255, 255)
|
||||
static let primary = color(17, 20, 25)
|
||||
static let primaryTop = color(43, 49, 58)
|
||||
static let primaryBottom = color(15, 18, 22)
|
||||
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, 249, 252)
|
||||
static let tileLine = color(233, 239, 247)
|
||||
static let faint = color(141, 152, 170)
|
||||
static let segmentTrack = color(236, 240, 246)
|
||||
static let segmentSelected = color(255, 255, 255)
|
||||
static let cardShadow = color(15, 30, 60)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -93,30 +106,39 @@ public enum AITheme {
|
||||
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 primaryTop = AIPalette.primaryTop
|
||||
public static let primaryBottom = AIPalette.primaryBottom
|
||||
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 compactButtonHeight: CGFloat = 44
|
||||
public static let largeButtonHeight: CGFloat = 56
|
||||
public static let toolbarButtonHeight: CGFloat = 44
|
||||
public static let iconButtonSize: 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
|
||||
|
||||
@@ -11,7 +11,8 @@ final class AIThemeTests: XCTestCase {
|
||||
XCTAssertEqual(AITheme.toolbarButtonHeight, 44, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.segmentedControlHeight, 44, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.buttonRadius, 16, accuracy: 0.1)
|
||||
XCTAssertEqual(AITheme.iconButtonSize, 44, 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)
|
||||
|
||||
@@ -6,7 +6,7 @@ final class AppMetadataTests: XCTestCase {
|
||||
func testMetadataUsesIPAFoundationContract() {
|
||||
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
|
||||
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
|
||||
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.0")
|
||||
XCTAssertEqual(AppMetadata.buildVersion, "3")
|
||||
XCTAssertEqual(AppMetadata.marketingVersion, "0.2.1")
|
||||
XCTAssertEqual(AppMetadata.buildVersion, "6")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,18 @@ import XCTest
|
||||
|
||||
@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()
|
||||
credentials: NoopCredentialStore(),
|
||||
preferences: transientPreferences()
|
||||
)
|
||||
try await model.add(InstanceInput(
|
||||
name: "Zulu",
|
||||
@@ -36,7 +42,8 @@ final class FleetViewModelTests: XCTestCase {
|
||||
let model = FleetViewModel(
|
||||
repository: repository,
|
||||
probe: ConnectionProbe(client: AlwaysOnlineClient()),
|
||||
credentials: NoopCredentialStore()
|
||||
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: []))
|
||||
@@ -48,14 +55,80 @@ final class FleetViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(model.groupedRows.flatMap(\.rows).map(\.instance.name), ["Home"])
|
||||
|
||||
model.searchText = ""
|
||||
model.groupMode = .tags
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["home", "未打标签"])
|
||||
XCTAssertEqual(model.groupedRows.map(\.rows.count), [1, 1])
|
||||
model.clearTagFilters()
|
||||
model.toggleTagFilter("Home")
|
||||
XCTAssertEqual(model.allTags, ["home"])
|
||||
XCTAssertEqual(model.visibleRows.map(\.instance.name), ["Home"])
|
||||
XCTAssertEqual(model.countForTagFilter("home"), 1)
|
||||
model.clearTagFilters()
|
||||
|
||||
model.groupMode = .status
|
||||
XCTAssertEqual(model.groupedRows.map(\.title), ["在线"])
|
||||
}
|
||||
|
||||
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 testGroupModeKeepsUngroupedInstancesVisible() async throws {
|
||||
let repository = MemoryInstanceRepository()
|
||||
let model = FleetViewModel(
|
||||
|
||||
@@ -15,7 +15,8 @@ final class MessageServiceTests: XCTestCase {
|
||||
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.id, "42")
|
||||
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")
|
||||
|
||||
@@ -11,10 +11,10 @@ final class SettingsViewModelTests: XCTestCase {
|
||||
await model.refresh()
|
||||
|
||||
XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin")
|
||||
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.0")
|
||||
XCTAssertEqual(model.runtime?.buildVersion, "3")
|
||||
XCTAssertEqual(model.runtime?.marketingVersion, "0.2.1")
|
||||
XCTAssertEqual(model.runtime?.buildVersion, "6")
|
||||
XCTAssertEqual(model.runtime?.platform, "iOS")
|
||||
XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false)
|
||||
XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false)
|
||||
|
||||
XCTAssertNil(model.errorMessage)
|
||||
}
|
||||
|
||||
@@ -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,40 @@
|
||||
# iOS Native 0.2.1
|
||||
|
||||
0.2.1 (6) 在 0.2.0 的基础上做界面与图标重构,功能集合不变。
|
||||
|
||||
## 本次变更
|
||||
|
||||
- 应用图标重做:改用与 Web 端 favicon 同一套几何的扁平标记——金色 SIM 卡体加三格递增信号,左上保留 SIM 切角。放弃原先的照片级立体卡,29pt 设置图标下也能读清轮廓。
|
||||
- 标签形状统一:详情页与日志页的状态标签改为与节点卡片一致的胶囊,不再一处圆角矩形、一处胶囊。
|
||||
- 深色主题下标签可见性提高,色雾与描边权重上调,避免浅底可读、深底消失。
|
||||
- 消息卡片去掉分隔线并压缩内边距,单屏可见消息数由 2.5 条提升到 3.5 条。
|
||||
- 节点详情的分区卡片收紧行距,"设备"分区同一屏位由 3 行增加到 5 行。
|
||||
- 节点详情头部指标由 8 行精简为 4 行(号码、运营商、网络、IP),数据模块由 6 枚胶囊改为一条读数,仅在缺模块时展开。
|
||||
- 批量选择勾选框移出实例名称区域,选中与未选中都不再压住文字。
|
||||
- 批量选择工具栏统一为 34pt 图标按钮,全选、删除、退出三个控件等高。
|
||||
- 分组编辑器改为点选即生效,去掉常驻禁用的"移出分组"按钮和说明文字。
|
||||
|
||||
## 数据与安全
|
||||
|
||||
与 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 或自有签名流程可以后续处理。
|
||||
Reference in New Issue
Block a user