release(ios): 发布 0.2.1 并重构图标与界面细节

- 应用图标改用与 Web favicon 同源的扁平标记,金色 SIM 卡体加三格递增信号,29pt 下仍可读
- 标签统一为胶囊形状,提高深色主题下的描边权重,避免标签在暗色下消失
- 消息卡片与详情分区卡片收紧行距,单屏信息密度明显提升
- 详情头部指标精简为 4 行,数据模块改为单条读数
- 批量选择勾选框移出名称区域,工具栏控件统一为等高 34pt
- 分组编辑器改为点选即生效,移除说明文字与常驻禁用按钮
This commit is contained in:
chick
2026-09-14 16:30:39 +08:00
parent 27687868ef
commit 3607eddc9f
34 changed files with 2656 additions and 1213 deletions
@@ -15,7 +15,8 @@ public enum SMSDirection: String, Equatable, Sendable {
}
public struct SMSMessage: Equatable, Identifiable, Sendable {
public let id: String
/// 上游消息 id 只在单个节点内唯一,聚合展示时不能直接当 Identifiable 用。
public let upstreamID: String
public let instanceID: UUID
public let instanceName: String
public let phoneNumber: String
@@ -23,6 +24,48 @@ public struct SMSMessage: Equatable, Identifiable, Sendable {
public let direction: SMSDirection
public let status: String
public let timestamp: String
public var id: String { "\(instanceID.uuidString)#\(upstreamID)" }
/// 上游 status 是英文原词,列表里直接展示会混进一行英文;未知值仍然原样显示,不做假结论。
public var statusLabel: String? {
let key = status.trimmingCharacters(in: .whitespaces).lowercased()
guard key.isEmpty == false else { return nil }
switch key {
case "received", "in", "incoming": return "已接收"
case "sent": return "已发送"
case "delivered": return "已送达"
case "read": return "已读"
case "failed", "error": return "发送失败"
case "pending", "queued", "sending": return "发送中"
default: return status
}
}
/// 收件的"已接收"和方向标签说的是同一件事,只有发件状态才值得再占一个位置。
public var deliveryLabel: String? {
guard direction == .outgoing else { return nil }
return statusLabel
}
public init(
upstreamID: String,
instanceID: UUID,
instanceName: String,
phoneNumber: String,
content: String,
direction: SMSDirection,
status: String,
timestamp: String
) {
self.upstreamID = upstreamID
self.instanceID = instanceID
self.instanceName = instanceName
self.phoneNumber = phoneNumber
self.content = content
self.direction = direction
self.status = status
self.timestamp = timestamp
}
}
public struct MessagePage: Equatable, Sendable {
@@ -133,7 +133,7 @@ public struct MessageService: Sendable {
: directionText == "outgoing" ? .outgoing
: .unknown
return SMSMessage(
id: id,
upstreamID: id,
instanceID: instance.id,
instanceName: instance.name,
phoneNumber: value["phone_number"] as? String ?? "",
@@ -12,24 +12,35 @@ struct MessagesView: View {
ScrollView {
LazyVStack(spacing: 12) {
filterBar
if let errorMessage = model.errorMessage {
AIStatusBanner(
errorMessage, tint: AITheme.warning, systemImage: "exclamationmark.triangle")
}
if model.isLoading && model.filteredRows.isEmpty {
AILoadingCard("正在同步消息数据")
}
if model.filteredRows.isEmpty && !model.isLoading {
} else if model.filteredRows.isEmpty {
AICard {
AIEmptyState(
systemImage: "message",
title: "暂无消息",
message: "刷新后从实例同步短信。"
)
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)
}
}
ForEach(model.filteredRows) { message in
messageCard(message)
}
}
.padding(16)
@@ -45,6 +56,14 @@ struct MessagesView: View {
}
.background(AITheme.canvas.ignoresSafeArea())
.navigationTitle("消息中心")
.toolbar {
ToolbarItem(placement: .primaryAction) {
AIIconButton("刷新", systemImage: "arrow.clockwise") {
Task { await model.refresh() }
}
.disabled(model.isLoading)
}
}
.confirmationDialog(
"删除这条消息?操作不可恢复。",
isPresented: Binding(
@@ -71,68 +90,83 @@ struct MessagesView: View {
}
private var filterBar: some View {
AICard {
VStack(spacing: 10) {
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))
}
VStack(spacing: 8) {
AISegmentedControl(
"消息方向",
selection: $model.direction,
options: MessagesViewModel.DirectionFilter.allCases.map {
(value: $0, title: $0.rawValue)
}
.pickerStyle(.menu)
.aiSelectSurface()
)
AISearchField("搜索号码或内容", text: $model.searchText)
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 {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
AICard(padding: 14) {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(message.phoneNumber.isEmpty ? "未知号码" : message.phoneNumber)
.font(.headline)
.font(.subheadline.weight(.semibold))
.foregroundStyle(AITheme.ink)
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer()
AITag(
message.direction.label,
tint: message.direction == .outgoing ? AITheme.primary : AITheme.success)
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)
HStack(alignment: .center, spacing: 10) {
Text(message.instanceName)
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)
}
Spacer(minLength: 8)
Button {
AISymbolAction("删除这条消息", systemImage: "trash", tint: AITheme.danger) {
pendingDeleteMessage = message
} label: {
Label("删除", systemImage: "trash")
}
.buttonStyle(
AIButtonStyle(
kind: .danger,
size: .compact,
fixedWidth: AITheme.listActionButtonWidth
)
)
}
}
}
@@ -61,9 +61,9 @@ final class MessagesViewModel: ObservableObject {
let page = try await service.messages(loaded, limit: 100)
rows = page.messages
} catch MessageServiceError.unauthorized {
errorMessage = "有实例需要登录,未获取受保护消息。"
errorMessage = "节点需要访问密码,请在编辑节点中填写后重试。"
} catch {
errorMessage = "无法加载消息。"
errorMessage = "所有节点都没有返回消息,可能节点离线或未开启短信接口。"
}
isLoading = false
}
@@ -80,7 +80,7 @@ final class MessagesViewModel: ObservableObject {
guard let instance = instances.first(where: { $0.id == message.instanceID }) else { return }
do {
try await service.delete([message.id], from: instance)
rows.removeAll { $0.id == message.id && $0.instanceID == message.instanceID }
rows.removeAll { $0.id == message.id }
} catch {
errorMessage = "删除消息失败。"
}