固定消息中心为首页入口,并在推送到达、已读后刷新桌面角标。 Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
12 KiB
Swift
342 lines
12 KiB
Swift
//
|
|
// MessageCenterModels.swift
|
|
// suixinkan
|
|
//
|
|
|
|
import Foundation
|
|
|
|
/// 可解码的 JSON 值,用于承接消息 `extra_data` 原始结构。
|
|
enum MessageJSONValue: Decodable, Hashable, Sendable {
|
|
case string(String)
|
|
case number(Double)
|
|
case bool(Bool)
|
|
case object([String: MessageJSONValue])
|
|
case array([MessageJSONValue])
|
|
case null
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.singleValueContainer()
|
|
if container.decodeNil() {
|
|
self = .null
|
|
} else if let value = try? container.decode(Bool.self) {
|
|
self = .bool(value)
|
|
} else if let value = try? container.decode(Double.self) {
|
|
self = .number(value)
|
|
} else if let value = try? container.decode(String.self) {
|
|
self = .string(value)
|
|
} else if let value = try? container.decode([MessageJSONValue].self) {
|
|
self = .array(value)
|
|
} else {
|
|
self = .object(try container.decode([String: MessageJSONValue].self))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 消息未读数量响应,用于同步首页红点和桌面图标角标。
|
|
struct MessageUnreadCountResponse: Decodable, Equatable, Sendable {
|
|
let unreadCount: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case unreadCount = "unread_count"
|
|
}
|
|
|
|
init(unreadCount: Int = 0) {
|
|
self.unreadCount = max(unreadCount, 0)
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0)
|
|
}
|
|
}
|
|
|
|
/// 全部标记已读响应,包含本次更新数量和操作后的未读数量。
|
|
struct MessageReadAllResponse: Decodable, Equatable, Sendable {
|
|
let updatedCount: Int
|
|
let unreadCount: Int
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case updatedCount = "updated_count"
|
|
case unreadCount = "unread_count"
|
|
}
|
|
|
|
init(updatedCount: Int = 0, unreadCount: Int = 0) {
|
|
self.updatedCount = max(updatedCount, 0)
|
|
self.unreadCount = max(unreadCount, 0)
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
updatedCount = max(try container.decodeLossyInt(forKey: .updatedCount) ?? 0, 0)
|
|
unreadCount = max(try container.decodeLossyInt(forKey: .unreadCount) ?? 0, 0)
|
|
}
|
|
}
|
|
|
|
/// 消息中心列表响应,对齐 Android `MsgResponse`。
|
|
struct MessageListResponse: Decodable, Equatable, Sendable {
|
|
let hasMore: Bool
|
|
let lastId: Int
|
|
let items: [MessageItem]
|
|
let isRefresh: Bool
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case hasMore = "has_more"
|
|
case lastId = "last_id"
|
|
case items
|
|
case isRefresh = "is_refresh"
|
|
}
|
|
|
|
init(hasMore: Bool = false, lastId: Int = 0, items: [MessageItem] = [], isRefresh: Bool = false) {
|
|
self.hasMore = hasMore
|
|
self.lastId = lastId
|
|
self.items = items
|
|
self.isRefresh = isRefresh
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
hasMore = try container.decodeLossyBool(forKey: .hasMore) ?? false
|
|
lastId = try container.decodeLossyInt(forKey: .lastId) ?? 0
|
|
items = (try? container.decodeIfPresent([MessageItem].self, forKey: .items)) ?? []
|
|
isRefresh = try container.decodeLossyBool(forKey: .isRefresh) ?? false
|
|
}
|
|
}
|
|
|
|
/// 单条消息实体,对齐 Android `MsgEntity`。
|
|
struct MessageItem: Decodable, Hashable, Sendable {
|
|
let id: Int
|
|
let receiverId: Int
|
|
let receiverType: String
|
|
let type: Int
|
|
let typeName: String
|
|
let title: String
|
|
let content: String
|
|
let pushAt: String
|
|
let isRead: Bool
|
|
let readAt: String
|
|
let pushChannel: Int
|
|
let pushChannelName: String
|
|
let extraData: [String: MessageJSONValue]?
|
|
let createdAt: String
|
|
let updatedAt: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case id
|
|
case receiverId = "receiver_id"
|
|
case receiverType = "receiver_type"
|
|
case type
|
|
case typeName = "type_name"
|
|
case title
|
|
case content
|
|
case pushAt = "push_at"
|
|
case isRead = "is_read"
|
|
case readAt = "read_at"
|
|
case pushChannel = "push_channel"
|
|
case pushChannelName = "push_channel_name"
|
|
case extraData = "extra_data"
|
|
case createdAt = "created_at"
|
|
case updatedAt = "updated_at"
|
|
}
|
|
|
|
init(
|
|
id: Int = 0,
|
|
receiverId: Int = 0,
|
|
receiverType: String = "",
|
|
type: Int = 0,
|
|
typeName: String = "",
|
|
title: String = "",
|
|
content: String = "",
|
|
pushAt: String = "",
|
|
isRead: Bool = false,
|
|
readAt: String = "",
|
|
pushChannel: Int = 0,
|
|
pushChannelName: String = "",
|
|
extraData: [String: MessageJSONValue]? = nil,
|
|
createdAt: String = "",
|
|
updatedAt: String = ""
|
|
) {
|
|
self.id = id
|
|
self.receiverId = receiverId
|
|
self.receiverType = receiverType
|
|
self.type = type
|
|
self.typeName = typeName
|
|
self.title = title
|
|
self.content = content
|
|
self.pushAt = pushAt
|
|
self.isRead = isRead
|
|
self.readAt = readAt
|
|
self.pushChannel = pushChannel
|
|
self.pushChannelName = pushChannelName
|
|
self.extraData = extraData
|
|
self.createdAt = createdAt
|
|
self.updatedAt = updatedAt
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
|
receiverId = try container.decodeLossyInt(forKey: .receiverId) ?? 0
|
|
receiverType = try container.decodeLossyString(forKey: .receiverType)
|
|
type = try container.decodeLossyInt(forKey: .type) ?? 0
|
|
typeName = try container.decodeLossyString(forKey: .typeName)
|
|
title = try container.decodeLossyString(forKey: .title)
|
|
content = try container.decodeLossyString(forKey: .content)
|
|
pushAt = try container.decodeLossyString(forKey: .pushAt)
|
|
isRead = try container.decodeLossyBool(forKey: .isRead) ?? false
|
|
readAt = try container.decodeLossyString(forKey: .readAt)
|
|
pushChannel = try container.decodeLossyInt(forKey: .pushChannel) ?? 0
|
|
pushChannelName = try container.decodeLossyString(forKey: .pushChannelName)
|
|
extraData = try? container.decodeIfPresent([String: MessageJSONValue].self, forKey: .extraData)
|
|
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
|
updatedAt = try container.decodeLossyString(forKey: .updatedAt)
|
|
}
|
|
|
|
/// Android 列表标题使用 `type_name`。
|
|
var displayTitle: String {
|
|
typeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
/// Android 列表优先展示 `push_at`,否则格式化 `created_at`。
|
|
var displayTime: String {
|
|
let pushTime = pushAt.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !pushTime.isEmpty { return pushTime }
|
|
return MessageDateFormatter.formatDateTime(createdAt) ?? createdAt
|
|
}
|
|
|
|
/// 返回已读状态的消息副本。
|
|
func markedRead() -> MessageItem {
|
|
MessageItem(
|
|
id: id,
|
|
receiverId: receiverId,
|
|
receiverType: receiverType,
|
|
type: type,
|
|
typeName: typeName,
|
|
title: title,
|
|
content: content,
|
|
pushAt: pushAt,
|
|
isRead: true,
|
|
readAt: readAt,
|
|
pushChannel: pushChannel,
|
|
pushChannelName: pushChannelName,
|
|
extraData: extraData,
|
|
createdAt: createdAt,
|
|
updatedAt: updatedAt
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 消息删除请求体,对齐 Android `MsgDeleteRequest`。
|
|
struct MessageDeleteRequest: Encodable, Sendable {
|
|
let id: Int
|
|
}
|
|
|
|
/// 消息中心日期显示工具,对齐 Android `DateUtils.formatDateTime` 的用户可见规则。
|
|
enum MessageDateFormatter {
|
|
private static let calendar = Calendar.current
|
|
|
|
static func formatDateTime(_ input: String?) -> String? {
|
|
guard let input = input?.trimmingCharacters(in: .whitespacesAndNewlines), !input.isEmpty else { return nil }
|
|
guard let date = parseDate(input) else { return nil }
|
|
|
|
let today = Date()
|
|
let startOfInput = calendar.startOfDay(for: date)
|
|
let startOfToday = calendar.startOfDay(for: today)
|
|
let dayDifference = calendar.dateComponents([.day], from: startOfInput, to: startOfToday).day ?? 0
|
|
let inputComponents = calendar.dateComponents([.year, .month, .day, .weekday], from: date)
|
|
let todayComponents = calendar.dateComponents([.year, .month], from: today)
|
|
|
|
if dayDifference == 0 {
|
|
return "今天"
|
|
}
|
|
if (1 ... 6).contains(dayDifference), let weekday = inputComponents.weekday {
|
|
return weekdayName(weekday)
|
|
}
|
|
if inputComponents.year == todayComponents.year, inputComponents.month == todayComponents.month {
|
|
return "本月\(inputComponents.day ?? 0)日"
|
|
}
|
|
if inputComponents.year == todayComponents.year {
|
|
return "\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日"
|
|
}
|
|
return "\(inputComponents.year ?? 0)年\(inputComponents.month ?? 0)月\(inputComponents.day ?? 0)日"
|
|
}
|
|
|
|
private static func parseDate(_ input: String) -> Date? {
|
|
if let timestamp = Double(input) {
|
|
let seconds = timestamp > 1_000_000_000_000 ? timestamp / 1000 : timestamp
|
|
return Date(timeIntervalSince1970: seconds)
|
|
}
|
|
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
formatter.timeZone = .current
|
|
for format in ["yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd"] {
|
|
formatter.dateFormat = format
|
|
if let date = formatter.date(from: input) {
|
|
return date
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private static func weekdayName(_ weekday: Int) -> String {
|
|
switch weekday {
|
|
case 1: return "周日"
|
|
case 2: return "周一"
|
|
case 3: return "周二"
|
|
case 4: return "周三"
|
|
case 5: return "周四"
|
|
case 6: return "周五"
|
|
case 7: return "周六"
|
|
default: return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension KeyedDecodingContainer {
|
|
func decodeLossyString(forKey key: Key) throws -> String {
|
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
|
return value
|
|
}
|
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
|
return String(value)
|
|
}
|
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
|
return String(value)
|
|
}
|
|
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
|
return value ? "true" : "false"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
|
return value
|
|
}
|
|
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
|
return Int(value)
|
|
}
|
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if let intValue = Int(text) { return intValue }
|
|
if let doubleValue = Double(text) { return Int(doubleValue) }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
|
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
|
return value
|
|
}
|
|
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
|
return value != 0
|
|
}
|
|
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
|
let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
if ["true", "1", "yes"].contains(text) { return true }
|
|
if ["false", "0", "no"].contains(text) { return false }
|
|
}
|
|
return nil
|
|
}
|
|
}
|