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.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) } } } } }