- 消息卡片删除按钮移出正文行,改用 overlay 悬在右侧,44pt 点击区不再把行撑高 - 批量标签胶囊由等宽栅格改为流式布局,新增 AIFlowLayout 并让节点页复用同一实现 - 设置页应用信息补「构建」一行,版本号全程不带括号 - 添加实例与发送消息两张表单表统一行内标题,去掉大标题留下的空白带 - 节点空态文案改为「节点未返回设备信息」,与量表条实际返回的数据不矛盾 - 图标石墨底、面板阴影与插槽统一压暖 3~4 级,全图 0 蓝色像素
190 lines
6.7 KiB
Swift
190 lines
6.7 KiB
Swift
import SwiftUI
|
|
|
|
struct MessagesView: View {
|
|
@StateObject private var model: MessagesViewModel
|
|
@State private var pendingDeleteMessage: SMSMessage?
|
|
|
|
init(model: MessagesViewModel? = nil) {
|
|
// StateObject 的 wrappedValue 是 autoclosure,在 init 里改它会写到另一个实例上,
|
|
// 所以调试开关必须先落到这个具体的 model 对象。
|
|
let target = model ?? DefaultContainer.makeMessagesModel()
|
|
#if DEBUG
|
|
if DebugLaunch.has("--sheet-send") {
|
|
target.showingSendForm = true
|
|
}
|
|
#endif
|
|
_model = StateObject(wrappedValue: target)
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
LazyVStack(spacing: 12) {
|
|
filterBar
|
|
if model.isLoading && model.filteredRows.isEmpty {
|
|
AILoadingCard("正在同步消息数据")
|
|
} else if model.filteredRows.isEmpty {
|
|
AICard {
|
|
if let errorMessage = model.errorMessage {
|
|
AIEmptyState(
|
|
systemImage: "arrow.triangle.2.circlepath",
|
|
title: "未能读取消息",
|
|
message: errorMessage,
|
|
actionTitle: "重试"
|
|
) {
|
|
Task { await model.refresh() }
|
|
}
|
|
} else {
|
|
AIEmptyState(
|
|
systemImage: "message",
|
|
title: "暂无消息",
|
|
message: "所有节点都没有返回短信。"
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
if let errorMessage = model.errorMessage {
|
|
AIStatusBanner(
|
|
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
|
|
}
|
|
ForEach(model.filteredRows) { message in
|
|
messageCard(message)
|
|
}
|
|
}
|
|
}
|
|
.padding(16)
|
|
.padding(.bottom, 8)
|
|
}
|
|
.background(AITheme.canvas.ignoresSafeArea())
|
|
.navigationTitle("消息中心")
|
|
.toolbar {
|
|
// 发送入口和节点页的「添加实例」保持一致:放在导航栏右侧,
|
|
// 底部固定条在 TabView 里会被自定义标签栏盖住,之前整条 CTA 是不可达的。
|
|
ToolbarItem(placement: .primaryAction) {
|
|
HStack(spacing: 8) {
|
|
AIIconButton("刷新", systemImage: "arrow.clockwise") {
|
|
Task { await model.refresh() }
|
|
}
|
|
.disabled(model.isLoading)
|
|
AIIconButton("发送消息", systemImage: "paperplane") {
|
|
model.showingSendForm = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"删除这条消息?操作不可恢复。",
|
|
isPresented: Binding(
|
|
get: { pendingDeleteMessage != nil },
|
|
set: { if !$0 { pendingDeleteMessage = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("删除消息", role: .destructive) {
|
|
guard let message = pendingDeleteMessage else { return }
|
|
Task { await model.delete(message) }
|
|
}
|
|
Button("取消", role: .cancel) {}
|
|
}
|
|
.refreshable {
|
|
await model.refresh()
|
|
}
|
|
.task {
|
|
await model.refresh()
|
|
}
|
|
.sheet(isPresented: $model.showingSendForm) {
|
|
SendMessageFormView(model: model)
|
|
}
|
|
}
|
|
|
|
private var filterBar: some View {
|
|
VStack(spacing: 8) {
|
|
AISegmentedControl(
|
|
"消息方向",
|
|
selection: $model.direction,
|
|
options: MessagesViewModel.DirectionFilter.allCases.map {
|
|
(value: $0, title: $0.rawValue)
|
|
}
|
|
)
|
|
|
|
Picker("实例", selection: $model.selectedInstanceID) {
|
|
Text("全部实例").tag(Optional<UUID>.none)
|
|
ForEach(model.instances) { instance in
|
|
Text(instance.name).tag(Optional(instance.id))
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.aiSelectSurface(compact: true)
|
|
// 选择器右侧原本是一整片空白,这里补上当前条数,让这一行读完就知道结果规模。
|
|
.overlay(alignment: .trailing) {
|
|
Text("\(model.filteredRows.count) 条")
|
|
.font(.caption.weight(.medium))
|
|
.monospacedDigit()
|
|
.foregroundStyle(AITheme.muted)
|
|
.padding(.trailing, 12)
|
|
.accessibilityHidden(true)
|
|
}
|
|
|
|
AISearchField("搜索号码或内容", text: $model.searchText, minHeight: AITheme.compactControlHeight)
|
|
}
|
|
}
|
|
|
|
|
|
private func messageCard(_ message: SMSMessage) -> some View {
|
|
AICard(padding: 14) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
|
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(AITheme.ink)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.layoutPriority(1)
|
|
AITag(
|
|
message.direction.label,
|
|
systemImage: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
|
|
Spacer(minLength: 8)
|
|
Text(TimestampText.short(message.timestamp))
|
|
.font(.caption2.weight(.medium))
|
|
.monospacedDigit()
|
|
.foregroundStyle(AITheme.faint)
|
|
.lineLimit(1)
|
|
.accessibilityLabel("时间 \(message.timestamp)")
|
|
}
|
|
Text(message.content)
|
|
.font(.subheadline)
|
|
.foregroundStyle(AITheme.ink)
|
|
.lineSpacing(2)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
// 删除按钮的点击区是 44pt,留在行内会把这一行撑到 44 高,正文与落款之间
|
|
// 因此凭空多出一条约 22pt 的空带。这里让落款按自身高度排,点击区挂在
|
|
// 行尾并垂直居中,多出的 12pt 落进卡片内边距,正文右缘留出等宽避让。
|
|
HStack(alignment: .center, spacing: 8) {
|
|
Label(message.instanceName, systemImage: "antenna.radiowaves.left.and.right")
|
|
.font(.caption)
|
|
.labelStyle(.titleAndIcon)
|
|
.imageScale(.small)
|
|
.foregroundStyle(AITheme.muted)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.layoutPriority(1)
|
|
if let delivery = message.deliveryLabel {
|
|
Text(delivery)
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(delivery == "发送失败" ? AITheme.danger : AITheme.faint)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(.trailing, 44)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.overlay(alignment: .trailing) {
|
|
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger, quiet: true) {
|
|
pendingDeleteMessage = message
|
|
}
|
|
// 图标在 44pt 点击区里居中,右移半个内边距让图标右缘与正文右缘对齐。
|
|
.offset(x: 12)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|