release(ios): 0.2.2 构建 9 修复消息发送遮挡并中性化石墨灰阶

- 消息中心发送入口从被标签栏盖住的底部操作条移到导航栏右侧,发信恢复可用
- 停用态主按钮保留主色淡底与同色描边,与次级按钮拉开层级
- 消息行删除改安静线性图标,去掉一屏红块
- 缺数据读数统一为短横,卡片延迟补空格单位
- 中性灰阶改为严格 R=G=B,浅色主题不再读出冷调;图标石墨底同步去蓝
- 详情页延迟量表改用与卡片同一算法,身份信息值改为右对齐
- 标签加号按钮与输入框同高同圆角
This commit is contained in:
chick
2026-09-15 08:19:18 +08:00
parent 9ad0c46088
commit c3d17b3daf
21 changed files with 203 additions and 125 deletions
+3
View File
@@ -14,3 +14,6 @@ data/
# 误在工作副本里跑构建时留下的缓存目录
apps/ios/apps/
# 图标由 Design/generate_app_icon.swift 重绘,产物不入库(正式资源在 Assets.xcassets)
apps/ios/Design/AppIcon.png
+1 -1
View File
@@ -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` 分支进行。
底部为节点、消息、设置三个入口:
+8 -8
View File
@@ -2,18 +2,18 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
<defs>
<linearGradient id="field" x1="0.06" y1="0" x2="0.94" y2="1">
<stop offset="0" stop-color="#202021"/>
<stop offset="0.55" stop-color="#131314"/>
<stop offset="1" stop-color="#0A0A0B"/>
<stop offset="0" stop-color="#202020"/>
<stop offset="0.55" stop-color="#131313"/>
<stop offset="1" stop-color="#0A0A0A"/>
</linearGradient>
<linearGradient id="brass" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#FADD98"/>
<stop offset="0.46" stop-color="#EEBF64"/>
<stop offset="1" stop-color="#C78D2F"/>
<stop offset="0" stop-color="#FCE5AA"/>
<stop offset="0.46" stop-color="#F0C76F"/>
<stop offset="1" stop-color="#D39B3D"/>
</linearGradient>
<linearGradient id="slot" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#101011"/>
<stop offset="1" stop-color="#202021"/>
<stop offset="0" stop-color="#101010"/>
<stop offset="1" stop-color="#202020"/>
</linearGradient>
</defs>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+15 -11
View File
@@ -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]
)
}
@@ -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"
}
@@ -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)
}
@@ -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)
@@ -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)
@@ -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 ?? "无错误码",
]
)
@@ -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) {
HStack(spacing: 8) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
AIIconButton("发送消息", systemImage: "paperplane") {
model.showingSendForm = true
}
}
}
}
.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
}
}
@@ -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 = "无法读取本地数据状态。"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 812 KiB

After

Width:  |  Height:  |  Size: 809 KiB

@@ -10,7 +10,7 @@
<key>CFBundleName</key><string>MultiSimAdmin</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>0.2.2</string>
<key>CFBundleVersion</key><string>8</string>
<key>CFBundleVersion</key><string>9</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
@@ -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,7 +121,9 @@ public struct AIButtonStyle: ButtonStyle {
style: .continuous
)
.strokeBorder(
size == .toolbarInline && kind == .primary
isTintedDisabled
? AITheme.primary.opacity(AITheme.disabledStrokeOpacity)
: size == .toolbarInline && kind == .primary
? AITheme.primary.opacity(0.28)
: (kind == .secondary || !isEnabled ? AITheme.line : .clear)
)
@@ -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,12 +847,14 @@ 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 {
if isQuiet == false {
RoundedRectangle(cornerRadius: AITheme.iconButtonRadius, style: .continuous)
.strokeBorder(tint.opacity(0.18))
}
}
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
@@ -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
@@ -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) },
[
@@ -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")
}
}
@@ -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)
+29 -4
View File
@@ -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`,未签名,
+6 -5
View File
@@ -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):历史设计稿