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