release(ios): 0.2.2 构建 10 修消息卡空洞、标签胶囊假宽与表单标题层级

- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高
- 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现
- 设置页应用信息补「构建」一行,版本号全程不带括号
- 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带
- 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾
- 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
This commit is contained in:
chick
2026-09-15 13:03:23 +08:00
parent c3d17b3daf
commit 802ddd1102
16 changed files with 138 additions and 82 deletions
@@ -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 = "9"
public static let buildVersion = "10"
}
@@ -71,11 +71,9 @@ struct FleetTagBatchSheet: View {
.font(.caption)
.foregroundStyle(AITheme.faint)
} else {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 112), alignment: .leading)],
alignment: .leading,
spacing: 8
) {
// 与节点卡片的标签行同一排法:胶囊按标签长度取宽。栅格会把每枚拉到
// 同一列宽,短标签右侧空出一片,看起来像控件没加载完。
AIFlowLayout(spacing: 8) {
ForEach(model.allTags, id: \.self) { tag in
CoverageChip(
title: tag,
@@ -87,6 +85,7 @@ struct FleetTagBatchSheet: View {
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@@ -182,7 +181,6 @@ struct FleetTagBatchSheet: View {
.monospacedDigit()
.opacity(0.72)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.frame(height: 38)
@@ -1170,7 +1170,9 @@ extension FleetView {
.padding(.vertical, 9)
.accessibilityLabel("正在读取运行信息")
} else {
Text("节点未返回运行信息")
// 这一区只放设备与容量读数;信号、延迟这些运行指标在上方的量表里,
// 说「未返回运行信息」会和同一张卡片上的延迟读数自相矛盾。
Text("节点未返回设备信息")
.font(.caption)
.foregroundStyle(AITheme.faint)
.padding(.vertical, 9)
@@ -1479,59 +1481,8 @@ extension FleetView {
}
}
struct FlowLayout: Layout {
let spacing: CGFloat
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var rowWidth: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
x = 0
y += rowHeight + spacing
rowHeight = 0
rowWidth = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
rowWidth = max(rowWidth, x - spacing)
}
return CGSize(width: rowWidth, height: max(0, y + rowHeight - (rowHeight > 0 ? 0 : spacing)))
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
/// 流式排布已提到主题层,这里保留原名,卡片标签行不用改调用点。
typealias FlowLayout = AIFlowLayout
struct FleetMetric: View {
let label: String
@@ -73,6 +73,9 @@ struct InstanceFormView: View {
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle(instance == nil ? "添加实例" : "编辑实例")
#if os(iOS)
// 表单表统一用行内标题:大标题会在卡片上方留出一整条空白,
// 而「批量标签」等其它表已经是行内样式。
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
}
@@ -5,7 +5,15 @@ struct MessagesView: View {
@State private var pendingDeleteMessage: SMSMessage?
init(model: MessagesViewModel? = nil) {
_model = StateObject(wrappedValue: model ?? DefaultContainer.makeMessagesModel())
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
// 所以调试开关必须先落到这个具体的 model 对象。
let target = model ?? DefaultContainer.makeMessagesModel()
#if DEBUG
if DebugLaunch.has("--sheet-send") {
target.showingSendForm = true
}
#endif
_model = StateObject(wrappedValue: target)
}
var body: some View {
@@ -147,6 +155,9 @@ struct MessagesView: View {
.foregroundStyle(AITheme.ink)
.lineSpacing(2)
.fixedSize(horizontal: false, vertical: true)
// 删除按钮的点击区是 44pt,留在行内会把这一行撑到 44 高,正文与落款之间
// 因此凭空多出一条约 22pt 的空带。这里让落款按自身高度排,点击区挂在
// 行尾并垂直居中,多出的 12pt 落进卡片内边距,正文右缘留出等宽避让。
HStack(alignment: .center, spacing: 8) {
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
.font(.caption)
@@ -162,10 +173,15 @@ struct MessagesView: View {
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
.lineLimit(1)
}
Spacer(minLength: 8)
}
.padding(.trailing, 44)
.frame(maxWidth: .infinity, alignment: .leading)
.overlay(alignment: .trailing) {
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger, quiet: true) {
pendingDeleteMessage = message
}
// 图标在 44pt 点击区里居中,右移半个内边距让图标右缘与正文右缘对齐。
.offset(x: 12)
}
}
}
@@ -101,6 +101,8 @@ struct SendMessageFormView: View {
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("发送消息")
#if os(iOS)
// 与「添加实例」一致:表行内标题,避免大标题在表单上方留出一整条空白。
.navigationBarTitleDisplayMode(.inline)
.scrollDismissesKeyboard(.interactively)
#endif
}
@@ -63,6 +63,8 @@ struct SettingsView: View {
AISectionTitle("应用信息", systemImage: "info.circle")
if let runtime = model.runtime {
AIKeyValueRow("版本", value: runtime.marketingVersion)
// 构建号单独一行:同一营销版本会连续出多个包,只写版本号分不清装的是哪个。
AIKeyValueRow("构建", value: runtime.buildVersion)
AIKeyValueRow("系统", value: runtime.systemVersion)
} else {
Text("正在读取应用信息…")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 809 KiB

After

Width:  |  Height:  |  Size: 970 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>9</string>
<key>CFBundleVersion</key><string>10</string>
<key>LSRequiresIPhoneOS</key><true/>
<key>MinimumOSVersion</key><string>16.0</string>
<key>NSAppTransportSecurity</key>
@@ -862,3 +862,63 @@ public struct AISymbolAction: View {
.accessibilityLabel(title)
}
}
/// 按固有尺寸换行排布的流式布局:标签胶囊这类「宽度取决于文字」的控件用它。
/// 栅格会把每枚胶囊拉到同一列宽,短标签右侧就空出一片,读起来像没加载完。
public struct AIFlowLayout: Layout {
public let spacing: CGFloat
public init(spacing: CGFloat = 8) {
self.spacing = spacing
}
public func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var x: CGFloat = 0
var y: CGFloat = 0
var rowHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > 0, x + size.width > maxWidth {
widestRow = max(widestRow, x - spacing)
y += rowHeight + spacing
x = 0
rowHeight = 0
}
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, x - spacing)
return CGSize(width: max(0, widestRow), height: max(0, y + rowHeight))
}
public func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x > bounds.minX, x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}