feat: 添加云盘与消息中心功能
This commit is contained in:
67
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
67
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
@ -0,0 +1,67 @@
|
||||
//
|
||||
// MessageCenterAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 消息中心服务协议,抽象列表、已读和删除接口。
|
||||
@MainActor
|
||||
protocol MessageCenterServing {
|
||||
/// 拉取消息列表。
|
||||
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
|
||||
|
||||
/// 标记消息已读。
|
||||
func markAsRead(messageId: Int) async throws
|
||||
|
||||
/// 删除消息。
|
||||
func delete(messageId: Int) async throws
|
||||
}
|
||||
|
||||
/// 消息中心 API,对齐 Android `NetworkApi` 的 msg 区域。
|
||||
@MainActor
|
||||
final class MessageCenterAPI: MessageCenterServing {
|
||||
private let client: APIClient
|
||||
|
||||
/// 初始化消息中心 API。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// GET /api/app/msg/list
|
||||
func list(lastId: Int = 0, limit: Int = 20, unread: Int = 0) async throws -> MessageListResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/msg/list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "last_id", value: String(max(lastId, 0))),
|
||||
URLQueryItem(name: "limit", value: String(max(limit, 1))),
|
||||
URLQueryItem(name: "unread", value: String(max(unread, 0))),
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/app/msg/read
|
||||
func markAsRead(messageId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/msg/read",
|
||||
queryItems: [URLQueryItem(name: "id", value: String(messageId))]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// POST /api/app/msg/delete
|
||||
func delete(messageId: Int) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/app/msg/delete",
|
||||
body: MessageDeleteRequest(id: messageId)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,301 @@
|
||||
//
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息中心列表响应,对齐 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,158 @@
|
||||
//
|
||||
// MessageCenterViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 消息中心列表页 ViewModel,负责分页、刷新和未读点击处理。
|
||||
final class MessageCenterViewModel {
|
||||
private(set) var items: [MessageItem] = []
|
||||
private(set) var isLoading = false
|
||||
private(set) var isRefreshing = false
|
||||
private(set) var isLoadingMore = false
|
||||
private(set) var canLoadMore = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onOpenDetail: ((MessageItem) -> Void)?
|
||||
|
||||
private let pageSize = 20
|
||||
private var lastId = 0
|
||||
|
||||
/// 首次加载消息列表。
|
||||
func loadInitial(api: any MessageCenterServing) async {
|
||||
await load(reset: true, showLoading: true, api: api)
|
||||
}
|
||||
|
||||
/// 下拉刷新消息列表。
|
||||
func refresh(api: any MessageCenterServing) async {
|
||||
guard !isLoading else { return }
|
||||
isRefreshing = true
|
||||
notifyStateChange()
|
||||
await load(reset: true, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 滚动到底部附近时加载更多。
|
||||
func loadMoreIfNeeded(lastVisibleIndex: Int, api: any MessageCenterServing) async {
|
||||
guard lastVisibleIndex >= items.count - 4 else { return }
|
||||
await load(reset: false, showLoading: false, api: api)
|
||||
}
|
||||
|
||||
/// 点击消息:未读先标记已读,成功后进入详情;已读直接进入详情。
|
||||
func selectMessage(id: Int, api: any MessageCenterServing) async {
|
||||
guard let item = items.first(where: { $0.id == id }) else { return }
|
||||
if item.isRead {
|
||||
onOpenDetail?(item)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
try await api.markAsRead(messageId: id)
|
||||
let readItem = item.markedRead()
|
||||
items = items.map { $0.id == id ? readItem : $0 }
|
||||
onOpenDetail?(readItem)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
onShowMessage?("标记已读失败")
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除后从本地列表移除消息。
|
||||
func removeMessage(id: Int) {
|
||||
items.removeAll { $0.id == id }
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
private func load(reset: Bool, showLoading: Bool, api: any MessageCenterServing) async {
|
||||
if reset {
|
||||
lastId = 0
|
||||
canLoadMore = false
|
||||
} else {
|
||||
guard canLoadMore, !isLoading, !isLoadingMore else { return }
|
||||
isLoadingMore = true
|
||||
}
|
||||
|
||||
isLoading = showLoading
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isLoading = false
|
||||
isRefreshing = false
|
||||
isLoadingMore = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await api.list(lastId: reset ? 0 : lastId, limit: pageSize, unread: 0)
|
||||
lastId = response.lastId
|
||||
items = reset ? response.items : items + response.items
|
||||
canLoadMore = response.hasMore && response.lastId > 0
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
if reset {
|
||||
items = []
|
||||
lastId = 0
|
||||
canLoadMore = false
|
||||
}
|
||||
onShowMessage?(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息详情页 ViewModel,负责删除当前消息。
|
||||
final class MessageDetailViewModel {
|
||||
private(set) var message: MessageItem
|
||||
private(set) var isDeleting = false
|
||||
|
||||
var onStateChange: (() -> Void)?
|
||||
var onShowMessage: ((String) -> Void)?
|
||||
var onDeleted: ((Int) -> Void)?
|
||||
|
||||
/// 初始化消息详情 ViewModel。
|
||||
init(message: MessageItem) {
|
||||
self.message = message
|
||||
}
|
||||
|
||||
/// 删除当前消息。
|
||||
func delete(api: any MessageCenterServing) async {
|
||||
guard message.id > 0 else {
|
||||
onShowMessage?("消息ID无效")
|
||||
return
|
||||
}
|
||||
|
||||
isDeleting = true
|
||||
notifyStateChange()
|
||||
defer {
|
||||
isDeleting = false
|
||||
notifyStateChange()
|
||||
}
|
||||
|
||||
do {
|
||||
try await api.delete(messageId: message.id)
|
||||
onShowMessage?("删除成功")
|
||||
onDeleted?(message.id)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
let detail = error.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onShowMessage?("删除失败: \(detail.isEmpty ? "请稍后重试" : detail)")
|
||||
}
|
||||
}
|
||||
|
||||
private func notifyStateChange() {
|
||||
onStateChange?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user