feat: 添加云盘与消息中心功能

This commit is contained in:
2026-07-09 22:37:43 +08:00
parent 8e356973bd
commit f20ec7f06c
91 changed files with 5437 additions and 89 deletions

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

View File

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

View File

@ -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?()
}
}