diff --git a/.gitignore b/.gitignore index 2d6d48e..0a0b6bf 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ data/ # 误在工作副本里跑构建时留下的缓存目录 apps/ios/apps/ + +# 图标由 Design/generate_app_icon.swift 重绘,产物不入库(正式资源在 Assets.xcassets) +apps/ios/Design/AppIcon.png diff --git a/README.md b/README.md index d480978..3a0ab94 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ## iOS Native 应用 -`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**,构建号 8,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。 +`apps/ios` 提供不依赖 8788 控制台的原生 iPhone 应用,当前版本 **0.2.2**,构建号 9,运行在 iOS 16 及以上,产物为未签名 IPA,供 TrollStore 或自有签名流程安装。iOS 开发在 `ios-native-ipa` 分支进行。 底部为节点、消息、设置三个入口: diff --git a/apps/ios/Design/AppIcon.svg b/apps/ios/Design/AppIcon.svg index 4eeb7b5..d24b93b 100644 --- a/apps/ios/Design/AppIcon.svg +++ b/apps/ios/Design/AppIcon.svg @@ -2,18 +2,18 @@ - - - + + + - - - + + + - - + + diff --git a/apps/ios/Design/generate_app_icon.swift b/apps/ios/Design/generate_app_icon.swift index 6d4bdb3..6507623 100644 --- a/apps/ios/Design/generate_app_icon.swift +++ b/apps/ios/Design/generate_app_icon.swift @@ -133,9 +133,10 @@ func directionalRim(_ path: CGPath, _ rect: CGRect, _ width: CGFloat, _ stops: [ fillLinear( rectPath(CGRect(x: 0, y: 0, width: side, height: side)), [ - (0.00, (32, 32, 33, 255)), - (0.55, (19, 19, 20, 255)), - (1.00, (10, 10, 11, 255)), + // 石墨底严格等色。原先 B 比 R 高 1 级,缩到 60pt 后边缘像素仍会落在蓝色波段。 + (0.00, (32, 32, 32, 255)), + (0.55, (19, 19, 19, 255)), + (1.00, (10, 10, 10, 255)), ] as [Stop], CGPoint(x: side * 0.06, y: 0), CGPoint(x: side * 0.94, y: side) @@ -169,9 +170,10 @@ ctx.restoreGState() fillLinear( plate, [ - (0.00, (250, 221, 152, 255)), - (0.46, (238, 191, 100, 255)), - (1.00, (199, 141, 47, 255)), + // 金色往香槟靠,收掉底部的芥末/棕调:小尺寸下这段最容易被读成"脏黄"。 + (0.00, (252, 229, 170, 255)), + (0.46, (240, 199, 111, 255)), + (1.00, (211, 155, 61, 255)), ] as [Stop], CGPoint(x: plateRect.minX, y: plateRect.minY), CGPoint(x: plateRect.maxX, y: plateRect.maxY) @@ -191,6 +193,8 @@ let slotWidth = envDouble("ICON_SLOT_W", 92) let slotGap = envDouble("ICON_SLOT_GAP", 54) let slotBottom = envDouble("ICON_SLOT_BOTTOM", 640) let slotHeights: [CGFloat] = [136, 232, 328] +// 说明:以上默认值等价于 plate 相对比例(宽 .155 / 间距 .091 / 底边内缩 .265), +// 改 plate 尺寸时按同一比例缩放才不会让槽和卡片脱节。 let slotsWidth = CGFloat(slotHeights.count) * slotWidth + CGFloat(slotHeights.count - 1) * slotGap let slotsStartX = (side - slotsWidth) / 2 @@ -207,8 +211,8 @@ for (index, height) in slotHeights.enumerated() { fillLinear( path, [ - (0.00, (16, 16, 17, 255)), - (1.00, (32, 32, 33, 255)), + (0.00, (16, 16, 16, 255)), + (1.00, (32, 32, 32, 255)), ] as [Stop], CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.minX, y: rect.maxY) @@ -217,9 +221,9 @@ for (index, height) in slotHeights.enumerated() { directionalRim( path, rect, radius * 0.30, [ - (0.00, (112, 74, 16, 100)), - (0.52, (112, 74, 16, 18)), - (1.00, (255, 236, 190, 60)), + (0.00, (120, 82, 24, 96)), + (0.52, (120, 82, 24, 16)), + (1.00, (255, 240, 202, 64)), ] as [Stop] ) } diff --git a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift index 3d2fbde..2a3f81d 100644 --- a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift +++ b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift @@ -2,5 +2,5 @@ public enum AppMetadata { public static let name = "MultiSimAdmin" public static let bundleIdentifier = "fun.chick.multi-simadmin" public static let marketingVersion = "0.2.2" - public static let buildVersion = "8" + public static let buildVersion = "9" } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift index 55bac15..7bf1bdf 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Device/InstanceDetailView.swift @@ -237,7 +237,9 @@ struct InstanceDetailView: View { section: "connectivity", keys: ["latency_ms", "latency"], format: loading ? nil : { "\(Int($0.rounded())) ms" }, - progress: loading ? nil : { _ in 1 }, + // 进度条读的是"链路质量",和节点卡片同一把尺子(1000ms 归零), + // 之前恒为 1,满格黑条压在 38ms 上会被读成"延迟占满了什么"。 + progress: loading ? nil : { max(0.04, min(1, 1 - $0 / 1000)) }, tint: loading ? nil : { latencyTint($0) } ) let cpu = summaryValue( @@ -562,18 +564,22 @@ struct InstanceDetailView: View { } else if items.isEmpty == false { VStack(alignment: .leading, spacing: 6) { ForEach(items, id: \.label) { item in - HStack(alignment: .firstTextBaseline, spacing: 8) { + // 值一律右对齐,和下方分区卡片的键值行同一节奏; + // 之前左边缩进 52pt、值顶在中间,同屏两种排法读起来像两张不同的表。 + HStack(alignment: .firstTextBaseline, spacing: 12) { Text(item.label) .font(.caption2.weight(.semibold)) .foregroundStyle(AITheme.muted) - .frame(width: 52, alignment: .leading) + .fixedSize(horizontal: true, vertical: false) + Spacer(minLength: 12) Text(item.value) .font(.caption.weight(.medium)) .monospacedDigit() .foregroundStyle(AITheme.ink) .lineLimit(1) .truncationMode(.middle) - Spacer(minLength: 0) + .multilineTextAlignment(.trailing) + .layoutPriority(1) } .accessibilityElement(children: .combine) } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift index 6dfb066..ae297de 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -180,6 +180,9 @@ struct FleetView: View { } private extension FleetView { + /// 概览读数缺数据时只画一根短横:「不可用」在四列窄格里既占位又容易被读成故障结论。 + static let noDataText = "—" + private static let relativeFormatter: RelativeDateTimeFormatter = { let formatter = RelativeDateTimeFormatter() formatter.unitsStyle = .abbreviated @@ -361,7 +364,7 @@ private extension FleetView { ) FleetHeadlineStat( title: "异常节点", - value: settledCount == 0 ? "--" : "\(abnormalCount)", + value: settledCount == 0 ? Self.noDataText : "\(abnormalCount)", detail: abnormalDetail, tint: abnormalTint ) @@ -666,7 +669,7 @@ private extension FleetView { private var hasSettledResults: Bool { summaryRows.isEmpty == false && settledCount > 0 } private var onlineRateValue: String { - guard hasSettledResults else { return "--" } + guard hasSettledResults else { return Self.noDataText } return "\(Int((Double(onlineCount) / Double(summaryRows.count) * 100).rounded()))%" } @@ -749,10 +752,10 @@ private extension FleetView { private var storageSummary: String { let pairs = storagePairs - guard pairs.isEmpty == false else { return "不可用" } + guard pairs.isEmpty == false else { return Self.noDataText } let used = pairs.map { max(0, $0.total - $0.available) }.reduce(0, +) let total = pairs.map(\.total).reduce(0, +) - guard total > 0 else { return "不可用" } + guard total > 0 else { return Self.noDataText } return "\(Int((Double(used) / Double(total) * 100).rounded()))% 已用" } @@ -777,7 +780,7 @@ private extension FleetView { private var trafficSummary: String { let values = summaryRows.compactMap(\.downloadRateBytesPerSecond) + summaryRows.compactMap(\.uploadRateBytesPerSecond) - guard values.isEmpty == false else { return "不可用" } + guard values.isEmpty == false else { return Self.noDataText } return ByteCountFormatter.string(fromByteCount: Int64(values.reduce(0, +)), countStyle: .decimal) + "/s" } @@ -793,7 +796,7 @@ private extension FleetView { } private func formatAverage(_ values: [Double], transform: (Double) -> String) -> String { - guard values.isEmpty == false else { return "不可用" } + guard values.isEmpty == false else { return Self.noDataText } let average = values.reduce(0, +) / Double(values.count) return transform(average) } @@ -927,25 +930,25 @@ private extension FleetView { [ FleetGauge( label: "信号", - value: row.signalPercent.map { "\($0)%" } ?? "--", + value: row.signalPercent.map { "\($0)%" } ?? Self.noDataText, ratio: row.signalPercent.map { Double($0) / 100 }, tint: row.signalPercent.flatMap(signalColor) ?? AITheme.muted ), FleetGauge( label: "延迟", - value: row.latencyMilliseconds.map { "\($0)ms" } ?? "--", + value: row.latencyMilliseconds.map { "\($0) ms" } ?? Self.noDataText, ratio: row.latencyMilliseconds.map { max(0, 1 - Double($0) / 1000) }, tint: row.latencyMilliseconds.flatMap(latencyColor) ?? AITheme.muted ), FleetGauge( label: "CPU", - value: row.cpuPercent.map(Self.percentText) ?? "--", + value: row.cpuPercent.map(Self.percentText) ?? Self.noDataText, ratio: row.cpuPercent.map { $0 / 100 }, tint: row.cpuPercent.flatMap(resourceColor) ?? AITheme.muted ), FleetGauge( label: "内存", - value: row.memoryPercent.map(Self.percentText) ?? "--", + value: row.memoryPercent.map(Self.percentText) ?? Self.noDataText, ratio: row.memoryPercent.map { $0 / 100 }, tint: row.memoryPercent.flatMap(resourceColor) ?? AITheme.muted ), @@ -993,7 +996,7 @@ private extension FleetView { private static func cpuValue(percent: Double?, cores: Int?) -> String? { guard percent != nil || cores != nil else { return nil } - let usage = percent.map(Self.percentText) ?? "--" + let usage = percent.map(Self.percentText) ?? FleetView.noDataText guard let cores, cores > 0 else { return usage } return "\(usage) · \(cores) 核" } @@ -1296,7 +1299,7 @@ extension FleetView { // 没有数据时用弱色占位,读起来不会像"0%"那样的假结论。 var isPlaceholder: Bool { - value == "--" || value == "不可用" || value == "暂无" + value == "--" || value == "—" || value == "不可用" || value == "暂无" } } @@ -1546,7 +1549,7 @@ extension FleetView { .font(.caption2) .foregroundStyle(AITheme.faint) .lineLimit(1) - Text(value ?? "--") + Text(value ?? FleetView.noDataText) .font(.footnote.weight(.semibold)) .monospacedDigit() .foregroundStyle(isAlert ? tint : AITheme.ink) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift index e21855a..93c4a02 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/TagEditorView.swift @@ -22,16 +22,17 @@ struct TagEditorView: View { commit(draft) } label: { Image(systemName: "plus") - .font(.system(size: 15, weight: .semibold)) - .frame(width: 44, height: 44) + // 加号按钮和左边的输入框同一高度、同一圆角,两个控件才读得出是一组。 + .font(.system(size: 16, weight: .semibold)) + .frame(width: AITheme.inputHeight, height: AITheme.inputHeight) .foregroundStyle(canAdd ? AITheme.primary : AITheme.muted) .background(canAdd ? AITheme.primary.opacity(0.10) : AITheme.control) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) + RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous) .strokeBorder(canAdd ? AITheme.primary.opacity(0.40) : AITheme.line) } - .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: AITheme.inputRadius, style: .continuous)) } .buttonStyle(.plain) .disabled(canAdd == false) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift index 9208fd6..892f71b 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogsView.swift @@ -237,7 +237,7 @@ struct LogsView: View { status: entry.outcome.label, statusTint: outcomeTint(entry.outcome), details: [ - "HTTP \(entry.httpStatus.map(String.init) ?? "--") · \(entry.durationMs) ms", + "HTTP \(entry.httpStatus.map(String.init) ?? "—") · \(entry.durationMs) ms", entry.errorCode ?? "无错误码", ] ) diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift index 8547f3d..cb117b0 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Messages/MessagesView.swift @@ -46,22 +46,21 @@ struct MessagesView: View { .padding(16) .padding(.bottom, 8) } - .aiBottomActionBar { - Button { - model.showingSendForm = true - } label: { - Label("发送消息", systemImage: "paperplane") - } - .buttonStyle(AIButtonStyle(kind: .primary, size: .regular, fillsWidth: true)) - } .background(AITheme.canvas.ignoresSafeArea()) .navigationTitle("消息中心") .toolbar { + // 发送入口和节点页的「添加实例」保持一致:放在导航栏右侧, + // 底部固定条在 TabView 里会被自定义标签栏盖住,之前整条 CTA 是不可达的。 ToolbarItem(placement: .primaryAction) { - AIIconButton("刷新", systemImage: "arrow.clockwise") { - Task { await model.refresh() } + HStack(spacing: 8) { + AIIconButton("刷新", systemImage: "arrow.clockwise") { + Task { await model.refresh() } + } + .disabled(model.isLoading) + AIIconButton("发送消息", systemImage: "paperplane") { + model.showingSendForm = true + } } - .disabled(model.isLoading) } } .confirmationDialog( @@ -164,7 +163,7 @@ struct MessagesView: View { .lineLimit(1) } Spacer(minLength: 8) - AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger) { + AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger, quiet: true) { pendingDeleteMessage = message } } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift index cde9e62..3c68927 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift @@ -15,7 +15,7 @@ struct SettingsRuntime: Equatable { final class SettingsViewModel: ObservableObject { @Published private(set) var runtime: SettingsRuntime? @Published private(set) var isLoading = false - @Published var storageLabel = "--" + @Published var storageLabel = "—" @Published var notice: String? @Published var errorMessage: String? @@ -61,7 +61,7 @@ final class SettingsViewModel: ObservableObject { let connectionCount = try await store.connectionLogs().count storageLabel = "\(runtimeCount) 条运行日志 · \(connectionCount) 条连接日志" } catch { - storageLabel = "--" + storageLabel = "—" errorMessage = "无法读取本地数据状态。" } } diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png index d3b8bff..8e3e724 100644 Binary files a/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png and b/apps/ios/Sources/MultiSimAdmin/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon1024.png differ diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist index 1444831..6fb57fc 100644 --- a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist @@ -10,7 +10,7 @@ CFBundleNameMultiSimAdmin CFBundlePackageTypeAPPL CFBundleShortVersionString0.2.2 - CFBundleVersion8 + CFBundleVersion9 LSRequiresIPhoneOS MinimumOSVersion16.0 NSAppTransportSecurity diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift index bb89325..f8b852c 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -21,10 +21,11 @@ public struct AIButtonStyle: ButtonStyle { } func backgroundColor(isEnabled: Bool) -> Color { - switch self { - case .primary, .secondary, .danger: - return isEnabled ? kindBackground : AITheme.control - } + if isEnabled { return kindBackground } + // 禁用态:实心主色按钮保留主色淡版,其余退回中性灰。 + return self == .primary + ? AITheme.primary.opacity(AITheme.disabledFillOpacity) + : AITheme.control } private var kindBackground: Color { @@ -34,6 +35,13 @@ public struct AIButtonStyle: ButtonStyle { case .danger: return AITheme.danger } } + + /// 禁用态下主按钮必须还能看出"这是主行动"。 + /// 之前三种 kind 禁用后都塌成同一块 control 灰,表单里"取消"和"保存"长得一模一样, + /// 用户只能靠文字猜哪个是提交。这里让实心主色按钮改用同色相淡填充 + 描边,灰底次按钮不套。 + var usesTintedDisabledTreatment: Bool { + self == .primary + } } public enum Size: Hashable { @@ -74,6 +82,8 @@ public struct AIButtonStyle: ButtonStyle { case .toolbarInline: AITheme.toolbarButtonHeight } + let isTintedDisabled = kind.usesTintedDisabledTreatment && !isEnabled + configuration.label .labelStyle(.titleAndIcon) .font( @@ -94,7 +104,7 @@ public struct AIButtonStyle: ButtonStyle { AnyShapeStyle(kind.backgroundColor(isEnabled: isEnabled)) ) .shadow( - color: (kind == .secondary || !isEnabled) ? .clear : .black.opacity(0.10), + color: (kind == .secondary || isTintedDisabled) ? .clear : .black.opacity(0.10), radius: 12, x: 0, y: 5 @@ -111,9 +121,11 @@ public struct AIButtonStyle: ButtonStyle { style: .continuous ) .strokeBorder( - size == .toolbarInline && kind == .primary - ? AITheme.primary.opacity(0.28) - : (kind == .secondary || !isEnabled ? AITheme.line : .clear) + isTintedDisabled + ? AITheme.primary.opacity(AITheme.disabledStrokeOpacity) + : size == .toolbarInline && kind == .primary + ? AITheme.primary.opacity(0.28) + : (kind == .secondary || !isEnabled ? AITheme.line : .clear) ) } .scaleEffect(configuration.isPressed ? Self.pressedScale : 1) diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift index 09f7f81..d10d80b 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -820,17 +820,22 @@ public struct AISymbolAction: View { private let title: String private let systemImage: String private let tint: Color + /// 安静样式:只留着色图标与点击区,不再铺色块。 + /// 卡片列表里每行都放一块彩色方块会把整页染成"警告",删除这类低频动作更适合安静样式。 + private let isQuiet: Bool private let action: () -> Void public init( _ title: String, systemImage: String, tint: Color = AITheme.muted, + quiet: Bool = false, action: @escaping () -> Void ) { self.title = title self.systemImage = systemImage self.tint = tint + self.isQuiet = quiet self.action = action } @@ -842,11 +847,13 @@ public struct AISymbolAction: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(tint) .frame(width: AITheme.iconButtonSize, height: AITheme.iconButtonSize) - .background(tint.opacity(0.10)) + .background(isQuiet ? Color.clear : tint.opacity(0.10)) .clipShape(RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous) - .strokeBorder(tint.opacity(0.18)) + if isQuiet == false { + RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous) + .strokeBorder(tint.opacity(0.18)) + } } .frame(width: 44, height: 44) .contentShape(Rectangle()) diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift index c8d412b..e7bc349 100644 --- a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -28,76 +28,77 @@ private enum AIPalette { dynamicColor(light: light, dark: dark) } - // 石墨灰阶:所有中性色 R≈G≈B,不带蓝调;主色让位给黑/白,颜色只用来表达状态。 - static let canvas = color(light: rgba(244, 245, 245, 1), dark: rgba(8, 9, 9, 1)) - static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(19, 21, 21, 1)) - static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(27, 29, 30, 1)) - static let control = color(light: rgba(238, 239, 239, 1), dark: rgba(36, 39, 40, 1)) - static let tile = color(light: rgba(246, 247, 247, 1), dark: rgba(255, 255, 255, 0.055)) - static let tileLine = color(light: rgba(231, 233, 233, 1), dark: rgba(255, 255, 255, 0.09)) - static let chrome = color(light: rgba(252, 252, 252, 0.92), dark: rgba(11, 12, 12, 0.88)) - static let ink = color(light: rgba(17, 19, 20, 1), dark: rgba(240, 242, 242, 1)) - static let muted = color(light: rgba(88, 94, 97, 1), dark: rgba(158, 165, 168, 1)) - static let faint = color(light: rgba(126, 132, 135, 1), dark: rgba(112, 118, 121, 1)) - static let line = color(light: rgba(223, 226, 226, 1), dark: rgba(255, 255, 255, 0.11)) - static let brand = color(light: rgba(18, 20, 21, 1), dark: rgba(8, 9, 9, 1)) - static let primary = color(light: rgba(21, 24, 25, 1), dark: rgba(238, 241, 241, 1)) - static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(12, 14, 14, 1)) + // 中性石墨灰阶:所有中性色严格 R=G=B,既不冷到读出蓝,也不暖到读出米色;主色让位给 + // 黑/白,颜色只用来表达状态。原先 B 比 R 高 3~10 级,浅色下大面积文字与描边会蒙一层冷调。 + static let canvas = color(light: rgba(245, 245, 245, 1), dark: rgba(10, 10, 10, 1)) + static let surface = color(light: rgba(255, 255, 255, 1), dark: rgba(21, 21, 21, 1)) + static let surfaceRaised = color(light: rgba(255, 255, 255, 1), dark: rgba(29, 29, 29, 1)) + static let control = color(light: rgba(239, 239, 239, 1), dark: rgba(39, 39, 39, 1)) + static let tile = color(light: rgba(247, 247, 247, 1), dark: rgba(255, 255, 255, 0.055)) + static let tileLine = color(light: rgba(232, 232, 232, 1), dark: rgba(255, 255, 255, 0.09)) + static let chrome = color(light: rgba(252, 252, 252, 0.92), dark: rgba(13, 13, 13, 0.88)) + static let ink = color(light: rgba(18, 18, 18, 1), dark: rgba(242, 242, 242, 1)) + static let muted = color(light: rgba(92, 92, 92, 1), dark: rgba(164, 164, 164, 1)) + static let faint = color(light: rgba(130, 130, 130, 1), dark: rgba(118, 118, 118, 1)) + static let line = color(light: rgba(226, 226, 226, 1), dark: rgba(255, 255, 255, 0.11)) + static let brand = color(light: rgba(18, 18, 18, 1), dark: rgba(9, 9, 9, 1)) + static let primary = color(light: rgba(23, 23, 23, 1), dark: rgba(240, 240, 240, 1)) + static let onPrimary = color(light: rgba(255, 255, 255, 1), dark: rgba(13, 13, 13, 1)) static let danger = color(light: rgba(193, 46, 40, 1), dark: rgba(247, 113, 105, 1)) static let onDanger = color(light: rgba(255, 255, 255, 1), dark: rgba(24, 8, 7, 1)) static let warning = color(light: rgba(170, 92, 15, 1), dark: rgba(233, 166, 60, 1)) static let success = color(light: rgba(16, 122, 86, 1), dark: rgba(66, 199, 132, 1)) - static let segmentTrack = color(light: rgba(234, 236, 236, 1), dark: rgba(26, 29, 29, 1)) - static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(54, 58, 59, 1)) - static let cardShadow = color(light: rgba(17, 22, 25, 0.06), dark: rgba(0, 0, 0, 0.60)) + static let segmentTrack = color(light: rgba(235, 235, 235, 1), dark: rgba(28, 28, 28, 1)) + static let segmentSelected = color(light: rgba(255, 255, 255, 1), dark: rgba(58, 58, 58, 1)) + static let cardShadow = color(light: rgba(18, 18, 18, 0.06), dark: rgba(0, 0, 0, 0.60)) #elseif canImport(AppKit) - static let canvas = Color(red: 0.957, green: 0.961, blue: 0.961) + static let canvas = Color(red: 0.961, green: 0.961, blue: 0.961) static let surface = Color.white - static let control = Color(red: 0.933, green: 0.937, blue: 0.937) - static let ink = Color(red: 0.067, green: 0.075, blue: 0.078) - static let muted = Color(red: 0.345, green: 0.369, blue: 0.380) - static let line = Color(red: 0.875, green: 0.886, blue: 0.886) - static let brand = Color(red: 0.071, green: 0.078, blue: 0.082) + static let control = Color(red: 0.937, green: 0.937, blue: 0.937) + static let ink = Color(red: 0.071, green: 0.071, blue: 0.071) + static let muted = Color(red: 0.361, green: 0.361, blue: 0.361) + static let line = Color(red: 0.886, green: 0.886, blue: 0.886) + static let brand = Color(red: 0.071, green: 0.071, blue: 0.071) static let surfaceRaised = Color.white static let chrome = Color.white.opacity(0.86) static let onPrimary = Color.white static let onDanger = Color.white - static let primary = Color(red: 0.082, green: 0.094, blue: 0.098) + static let primary = Color(red: 0.090, green: 0.090, blue: 0.090) static let danger = Color(red: 0.757, green: 0.180, blue: 0.157) static let warning = Color(red: 0.667, green: 0.361, blue: 0.059) static let success = Color(red: 0.063, green: 0.478, blue: 0.337) - static let tile = Color(red: 0.965, green: 0.969, blue: 0.969) - static let tileLine = Color(red: 0.906, green: 0.914, blue: 0.914) - static let faint = Color(red: 0.494, green: 0.518, blue: 0.529) - static let segmentTrack = Color(red: 0.918, green: 0.925, blue: 0.925) + static let tile = Color(red: 0.969, green: 0.969, blue: 0.969) + static let tileLine = Color(red: 0.910, green: 0.910, blue: 0.910) + static let faint = Color(red: 0.510, green: 0.510, blue: 0.510) + static let segmentTrack = Color(red: 0.922, green: 0.922, blue: 0.922) static let segmentSelected = Color.white - static let cardShadow = Color(red: 0.067, green: 0.086, blue: 0.098).opacity(0.06) + static let cardShadow = Color(red: 0.071, green: 0.071, blue: 0.071).opacity(0.06) #else private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> Color { Color(red: red / 255, green: green / 255, blue: blue / 255) } - static let canvas = color(243, 245, 248) + static let canvas = color(245, 245, 245) static let surface = color(255, 255, 255) - static let control = color(247, 249, 251) - static let ink = color(28, 39, 56) - static let muted = color(111, 124, 143) - static let line = color(224, 229, 235) - static let brand = color(19, 30, 48) - static let surfaceRaised = color(251, 252, 253) + static let control = color(239, 239, 239) + static let ink = color(18, 18, 18) + static let muted = color(92, 92, 92) + static let line = color(226, 226, 226) + static let brand = color(18, 18, 18) + static let surfaceRaised = color(255, 255, 255) static let chrome = color(255, 255, 255, 0.82) static let onPrimary = color(255, 255, 255) static let onDanger = color(255, 255, 255) - static let primary = color(17, 20, 25) + static let primary = color(23, 23, 23) static let danger = color(190, 65, 57) static let warning = color(170, 111, 23) static let success = color(50, 150, 101) - static let tile = color(247, 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 tile = color(247, 247, 247) + static let tileLine = color(232, 232, 232) + static let faint = color(130, 130, 130) + static let segmentTrack = color(235, 235, 235) static let segmentSelected = color(255, 255, 255) - static let cardShadow = color(15, 30, 60) + static let cardShadow = color(18, 18, 18) #endif } @@ -125,6 +126,9 @@ public enum AITheme { public static let cardShadow = AIPalette.cardShadow public static let buttonHeight: CGFloat = 48 + // 停用态主按钮保留一层主色淡底,避免和次级按钮长得一模一样,层级读不出来。 + public static let disabledFillOpacity: Double = 0.14 + public static let disabledStrokeOpacity: Double = 0.22 public static let compactButtonHeight: CGFloat = 44 public static let largeButtonHeight: CGFloat = 56 public static let toolbarButtonHeight: CGFloat = 44 diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift index 9c49aaa..3e04dd1 100644 --- a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift @@ -77,7 +77,20 @@ final class AIThemeTests: XCTestCase { ] let disabledColors = disabledKinds.map { $0.backgroundColor(isEnabled: false) } - XCTAssertEqual(Set(disabledColors), [AITheme.control]) + // 停用态仍然要保住按钮层级:主按钮留一层主色淡底,次级/危险退回中性 control。 + XCTAssertEqual(disabledColors.count, 3) + XCTAssertEqual(disabledColors[1], AITheme.control) + XCTAssertEqual(disabledColors[2], AITheme.control) + XCTAssertTrue(disabledColors[0] != AITheme.control) + // 只有实心主色按钮需要"同色相淡填充"处理,灰底次按钮不能套,否则两者又会长一样。 + XCTAssertEqual( + [ + AIButtonStyle.Kind.primary, + .secondary, + .danger, + ].map(\.usesTintedDisabledTreatment), + [true, false, false] + ) XCTAssertEqual( disabledKinds.map { $0.foregroundColor(isEnabled: false) }, [ diff --git a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift index 4f970e6..4784460 100644 --- a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift @@ -7,6 +7,6 @@ final class AppMetadataTests: XCTestCase { XCTAssertEqual(AppMetadata.name, "MultiSimAdmin") XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin") XCTAssertEqual(AppMetadata.marketingVersion, "0.2.2") - XCTAssertEqual(AppMetadata.buildVersion, "8") + XCTAssertEqual(AppMetadata.buildVersion, "9") } } diff --git a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift index 655ab61..c401ebc 100644 --- a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift @@ -12,7 +12,7 @@ final class SettingsViewModelTests: XCTestCase { XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin") XCTAssertEqual(model.runtime?.marketingVersion, "0.2.2") - XCTAssertEqual(model.runtime?.buildVersion, "8") + XCTAssertEqual(model.runtime?.buildVersion, "9") XCTAssertEqual(model.runtime?.platform, "iOS") XCTAssertTrue(model.runtime?.systemVersion.hasPrefix("iOS") ?? false) diff --git a/docs/ios-native-0.2.2.md b/docs/ios-native-0.2.2.md index 1f1c958..01d1aff 100644 --- a/docs/ios-native-0.2.2.md +++ b/docs/ios-native-0.2.2.md @@ -1,11 +1,36 @@ # iOS Native 0.2.2 -0.2.2 构建 8 把「分组」这个实体从应用里彻底删掉,归类只靠标签;同时完成第二轮配色与图标重构, -界面上不再出现蓝色。页面能力、上游白名单与本地数据布局见现行说明 [docs/ios-native.md](ios-native.md), +0.2.2 把「分组」这个实体从应用里彻底删掉,归类只靠标签;同时完成第二轮配色与图标重构,界面上 +不再出现蓝色。构建 9 是一轮收尾打磨:修掉消息中心发不出去信的按钮遮挡,补齐按钮层级,统一缺数据 +读数的写法,并把中性灰阶压成严格等色。页面能力、上游白名单与本地数据布局见现行说明 +[docs/ios-native.md](ios-native.md), 本文只记录本次发布的变化。 ## 本次变更 +- 中性灰阶改为严格 R=G=B:此前所有灰底的蓝通道比红通道高 3~10 级,浅色主题下大面积文字与描边 + 会蒙一层冷调,截图取色仍落在蓝色波段。现在画布、卡片、控件、描边、文字全部等色,画面里 + 只剩红(危险)、橙(告警)、绿(正常)三类状态色。 +- 延迟量表改为可读量:详情页延迟进度条此前恒为满格,38ms 和 900ms 画出来一模一样。现在与节点 + 卡片同一算法(越短越满,1s 归零),一根条真的在说延迟好坏。 +- 单位与占位写法统一:卡片里的延迟从 `218ms` 改成 `218 ms`,与概览、详情同一写法;设置页日志 + 计数与日志中心的 HTTP 状态占位也从 `--` 收敛到 `—`。 +- 表单里成对的控件同尺寸:标签输入框高 52,右侧加号按钮只有 44,两个控件并排时明显一高一矮。 + 现在加号与输入框同高同圆角。 +- 详情页身份信息对齐方式统一:头部摘要的值此前左对齐成一列,下方分区卡片的值右对齐,同一屏两种 + 排法读起来像两张不同的表。现在两处都靠右。 +- 修复消息中心无法发信:发送入口原先挂在页面底部的操作条上,而底部标签栏是用 `safeAreaInset` + 贴在根视图上的,两者叠在同一位置,操作条整条被标签栏盖住,「发送消息」在真机上根本点不到。 + 现在发送与刷新一起放进导航栏右侧,节点总览、设置页同一区域同一套做法。 +- 主按钮禁用态重新分级:禁用前的主要按钮与普通按钮像素一致,表单里「取消」和「保存实例」分不出 + 主次。现在禁用的主要按钮改用主色低透明度底加同色描边,并去掉投影,看得见但明显弱于可用状态。 +- 消息行的删除按钮降噪:原先每条消息都带一枚红色实心方块按钮,一屏十几个红块。现在改成一个 + 红色线性图标,保留 44pt 点击区域,只在需要时提示危险语义。 +- 缺数据读数统一为一根短横:概览与卡片读数此前混用「不可用」和 `--`,四列窄格里既容易被读成 + 故障结论,字号还会被压缩。现在一律用 `—`,并按占位弱色渲染。 +- 应用图标金色再收一档:底部由深古铜提亮到香槟金,整块金属在高亮壁纸上不再往下沉成棕黄。 + 石墨底同步去掉 1 级的蓝通道偏差,1024 与 120 两档取色都不再落在蓝色波段。 + - 分组概念移除:删除分组视图、分组编辑器与分组仓储,实例模型不再有 `groupId`。升级后首次启动 一次性删除 `Documents/instance-groups.json` 与偏好键 `fleet.groupMode`,本机不留死数据。 - 标签接管归类:新增「批量标签」表,批量选择后一次给多台节点贴同一个标签;每个标签显示 @@ -65,10 +90,10 @@ - `build/ipa/MultiSimAdmin.ipa` - `build/ipa/MultiSimAdmin.ipa.sha256` -0.2.2 构建 8 校验值: +0.2.2 构建 9 校验值: ```text -a40e8e308f667edfe69902d5665a9ad85bdb52f72b5a248a671b6eef3591d847 MultiSimAdmin.ipa +a0b90a3754bd5cb8d9b95906eb711b7d140d603b6f821ee838edb273e62e3d75 MultiSimAdmin.ipa ``` IPA 目标为 iOS 16.0 及以上,`arm64`,Bundle ID 为 `fun.chick.multi-simadmin`,未签名, diff --git a/docs/ios-native.md b/docs/ios-native.md index bbc5a8c..a5da656 100644 --- a/docs/ios-native.md +++ b/docs/ios-native.md @@ -3,7 +3,7 @@ 本文是 iPhone 应用的唯一现行说明,内容与仓库代码一致。历史设计稿和执行计划保留在 `docs/superpowers/` 下,其中已被实现推翻的决策在本文第 9 节逐条列出。 -- 当前版本:`0.2.2`,构建号 8 +- 当前版本:`0.2.2`,构建号 9 - 承载分支:`ios-native-ipa` - 运行要求:iOS 16.0 及以上,`arm64`,Bundle ID `fun.chick.multi-simadmin` - 产物:未签名 `MultiSimAdmin.ipa`,由 TrollStore 或用户自有签名流程安装 @@ -34,8 +34,8 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。 - 运行概览:在线率与异常节点两枚大字结论、状态分布条与图例、八格两行的群集读数(信号、延迟、 存储、流量、CPU、内存、温度、电量),可折叠,折叠时保留前四格。检测进度与更新时间写在卡片 - 副标题里(`刚刚更新 · 已检测 6/6 台`),不占读数格;整群节点没有上报的读数保留「不可用」占位, - 栅格不会在七格与九格之间跳动。 + 副标题里(`刚刚更新 · 已检测 6/6 台`),不占读数格;整群节点没有上报的读数保留占位,统一写成 + 一根短横 `—`,避免在四列窄格里被读成故障结论;栅格不会在七格与九格之间跳动。 - 标签归类:标签是唯一归类手段,分组概念已在 0.2.2 移除。标签条支持多选叠加,胶囊上直接显示 命中台数,可单个取消或全部清除;卡片内的标签同样可点,点一下即加入筛选。 - 批量标签:批量选择后一次给多台节点贴同一个标签,每个标签显示「未贴 / N/M / 全部」三态覆盖度, @@ -64,7 +64,8 @@ Node 控制面仍然独立存在,两者只共享上游接口契约。 - 列表按时间排序,显示来源实例、方向、号码、正文与相对时间。 - 支持按实例筛选、按上行/下行筛选、按号码或内容搜索。 -- 发送写入目标实例;失败可重试;单条删除走该实例的上游接口。 +- 发送与刷新一起放在导航栏右侧,页脚不再另挂操作条;发送写入目标实例,失败可重试; + 单条删除走该实例的上游接口,行内只留一枚红色线性图标。 ### 日志中心与设置 @@ -197,7 +198,7 @@ xcodebuild -scheme MultiSimAdmin \ ## 11. 文档索引 - [docs/ios-native.md](ios-native.md):本文,现行说明 -- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 构建 8 发布说明与校验值 +- [docs/ios-native-0.2.2.md](ios-native-0.2.2.md):0.2.2 构建 9 发布说明与校验值 - [docs/ios-native-0.2.1.md](ios-native-0.2.1.md):0.2.1 构建 6 发布说明与校验值 - [docs/ios-native-0.2.0.md](ios-native-0.2.0.md):0.2.0 发布说明 - [docs/superpowers/specs/2026-09-08-ios-native-ipa-design.md](superpowers/specs/2026-09-08-ios-native-ipa-design.md):历史设计稿