Initial commit: suixinkan_ios UIKit rewrite project.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 14:33:31 +08:00
commit 9edf993432
297 changed files with 47151 additions and 0 deletions

View File

@ -0,0 +1,72 @@
//
// MessageCenterAPI.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
///
@MainActor
protocol MessageCenterServing {
/// 使 last_id
func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse
///
func messageRead(id: Int) async throws
///
func messageDelete(id: Int) async throws
}
@MainActor
/// API
final class MessageCenterAPI {
private let client: APIClient
/// API
init(client: APIClient) {
self.client = client
}
/// 使 last_id
func messageList(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)))
]
)
)
}
///
func messageRead(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/msg/read",
queryItems: [URLQueryItem(name: "id", value: String(id))],
body: EmptyPayload()
)
)
}
///
func messageDelete(id: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/msg/delete",
body: MessageDeleteRequest(id: id)
)
)
}
}
extension MessageCenterAPI: MessageCenterServing {}

View File

@ -0,0 +1,18 @@
# 消息中心模块
## 模块职责
`Features/MessageCenter` 承接首页 `message_center` 权限入口,负责消息列表、未读筛选、标记已读、全部已读、详情展示和删除。
## 代码结构
- `MessageCenterAPI`:封装 `/api/app/msg/list``/api/app/msg/read``/api/app/msg/delete`
- `MessageCenterViewModel`:管理 `last_id` 游标分页、筛选、去重排序、已读和删除后的本地状态同步。
- `MessageCenterView`:提供统计卡、全部/未读筛选、列表、失败重试、空态、详情和删除确认。
## 数据与边界
- 消息详情不请求独立详情接口,直接使用列表返回的标题、内容、类型和推送时间。
- 点击消息时先调用已读接口,再进入详情,并将本地未读状态同步为已读。
- 全部已读沿用旧 iOS 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。
- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。

View File

@ -0,0 +1,226 @@
//
// MessageCenterModels.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
import UIKit
/// 使 last_id
struct MessageListResponse: Decodable, Equatable {
let hasMore: Bool
let lastId: Int
let items: [MessageEntity]
enum CodingKeys: String, CodingKey {
case hasMore = "has_more"
case lastId = "last_id"
case items
}
///
init(hasMore: Bool = false, lastId: Int = 0, items: [MessageEntity] = []) {
self.hasMore = hasMore
self.lastId = lastId
self.items = items
}
/// //
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([MessageEntity].self, forKey: .items) ?? []
}
}
///
struct MessageEntity: Decodable, Equatable, Identifiable {
let id: Int
let type: Int
let typeName: String
let title: String
let content: String
let pushAt: String
let createdAt: String
let isRead: Bool
enum CodingKeys: String, CodingKey {
case id
case type
case typeName = "type_name"
case title
case content
case pushAt = "push_at"
case createdAt = "created_at"
case isRead = "is_read"
}
///
init(
id: Int,
type: Int,
typeName: String = "",
title: String = "",
content: String = "",
pushAt: String = "",
createdAt: String = "",
isRead: Bool = false
) {
self.id = id
self.type = type
self.typeName = typeName
self.title = title
self.content = content
self.pushAt = pushAt
self.createdAt = createdAt
self.isRead = isRead
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decodeLossyInt(forKey: .id) ?? 0
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)
createdAt = try container.decodeLossyString(forKey: .createdAt)
isRead = try container.decodeLossyBool(forKey: .isRead) ?? false
}
}
///
struct MessageDeleteRequest: Encodable {
let id: Int
}
///
enum MessageType: Equatable {
case order
case writeOff
case system
///
var iconName: String {
switch self {
case .order:
return "cart.fill"
case .writeOff:
return "qrcode.viewfinder"
case .system:
return "bell.badge.fill"
}
}
///
var title: String {
switch self {
case .order:
return "订单"
case .writeOff:
return "核销"
case .system:
return "系统"
}
}
///
var color: UIColor {
switch self {
case .order:
return AppDesign.primary
case .writeOff:
return AppDesign.success
case .system:
return AppDesign.warning
}
}
}
///
struct MessageItem: Identifiable, Equatable {
let id: String
let msgId: Int?
let title: String
let detail: String
let time: String
var isRead: Bool
let type: MessageType
}
///
enum MessageFilter: Int, CaseIterable, Identifiable {
case all = 0
case unread = 1
var id: Int { rawValue }
///
var title: String {
switch self {
case .all:
return "全部"
case .unread:
return "未读"
}
}
}
private extension KeyedDecodingContainer {
/// String Bool
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 ? "1" : "0"
}
return ""
}
/// String Bool Int
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),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
return Int(text) ?? Int(Double(text) ?? 0)
}
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
return value ? 1 : 0
}
return nil
}
/// String Bool Bool
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),
case let text = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!text.isEmpty {
if ["1", "true", "yes"].contains(text) { return true }
if ["0", "false", "no"].contains(text) { return false }
}
return nil
}
}

View File

@ -0,0 +1,69 @@
//
// MessageCenterViewController.swift
// suixinkan
//
// Created by Codex on 2026/6/26.
//
import UIKit
///
final class MessageCenterViewController: ModuleTableViewController {
private let viewModel = MessageCenterViewModel()
override func viewDidLoad() {
title = "消息中心"
navigationItem.rightBarButtonItem = UIBarButtonItem(
title: "全部已读",
style: .plain,
target: self,
action: #selector(markAllRead)
)
super.viewDidLoad()
wireViewModel(viewModel) { [weak self] in self?.updateBarButtons() }
}
override func tableRowCount() -> Int {
viewModel.messages.count
}
override func configureCell(_ cell: TitleSubtitleTableViewCell, at indexPath: IndexPath) {
let item = viewModel.messages[indexPath.row]
cell.configure(
title: item.title,
subtitle: item.detail,
detail: item.isRead ? item.time : "未读 · \(item.time)"
)
cell.accessoryType = item.isRead ? .none : .disclosureIndicator
}
override func didSelectTableRow(at indexPath: IndexPath) {
let item = viewModel.messages[indexPath.row]
Task {
try? await viewModel.markAsRead(api: services.messageCenterAPI, item: item)
showAlert(title: item.title, message: item.detail)
}
}
override func reloadContent() async {
isLoading = viewModel.messages.isEmpty
await viewModel.reloadFirstPage(api: services.messageCenterAPI)
isLoading = false
}
@objc private func markAllRead() {
Task { try? await viewModel.markAllAsRead(api: services.messageCenterAPI) }
}
private func updateBarButtons() {
navigationItem.rightBarButtonItem?.isEnabled = viewModel.unreadCount > 0 && !viewModel.messages.isEmpty
}
private func showAlert(title: String, message: String) {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "知道了", style: .default))
present(alert, animated: true)
}
}
extension MessageCenterViewModel: ViewModelBindable {}

View File

@ -0,0 +1,169 @@
//
// MessageCenterViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import Foundation
@MainActor
/// ViewModel
final class MessageCenterViewModel {
var onChange: (() -> Void)?
var messages: [MessageItem] = [] { didSet { onChange?() } }
var selectedFilter: MessageFilter = .all { didSet { onChange?() } }
var isLoading = false { didSet { onChange?() } }
var isLoadingMore = false { didSet { onChange?() } }
var loadFailed = false { didSet { onChange?() } }
var loadFailureReason: String? { didSet { onChange?() } }
var message: String? { didSet { onChange?() } }
private(set) var hasMoreMessages = false { didSet { onChange?() } }
private(set) var lastId = 0 { didSet { onChange?() } }
private let pageSize = 20
///
var unreadCount: Int {
messages.filter { !$0.isRead }.count
}
///
var filterDescription: String {
selectedFilter == .all ? "显示全部消息" : "仅显示未读消息"
}
///
func selectFilter(_ filter: MessageFilter, api: any MessageCenterServing) async {
guard selectedFilter != filter else { return }
selectedFilter = filter
await reloadFirstPage(api: api)
}
///
func reloadFirstPage(api: any MessageCenterServing) async {
isLoading = true
loadFailed = false
loadFailureReason = nil
message = nil
defer { isLoading = false }
do {
let response = try await api.messageList(lastId: 0, limit: pageSize, unread: selectedFilter.rawValue)
messages = mapMessages(response.items)
hasMoreMessages = response.hasMore
lastId = response.lastId
} catch {
clearMessages()
loadFailed = true
loadFailureReason = error.localizedDescription
message = error.localizedDescription
}
}
///
func loadMore(api: any MessageCenterServing) async {
guard hasMoreMessages, !isLoadingMore, !isLoading else { return }
isLoadingMore = true
message = nil
defer { isLoadingMore = false }
do {
let response = try await api.messageList(lastId: lastId, limit: pageSize, unread: selectedFilter.rawValue)
messages.append(contentsOf: mapMessages(response.items))
messages = deduplicatedAndSorted(messages)
hasMoreMessages = response.hasMore
lastId = response.lastId
} catch {
message = error.localizedDescription
}
}
///
func markAsRead(api: any MessageCenterServing, item: MessageItem) async throws {
guard !item.isRead, let msgId = item.msgId else { return }
try await api.messageRead(id: msgId)
guard let index = messages.firstIndex(where: { $0.id == item.id }) else { return }
messages[index].isRead = true
}
///
func markAllAsRead(api: any MessageCenterServing) async throws {
let original = messages
do {
for item in messages where !item.isRead {
if let msgId = item.msgId {
try await api.messageRead(id: msgId)
}
}
messages = messages.map { item in
var next = item
next.isRead = true
return next
}
} catch {
messages = original
throw error
}
}
///
func deleteMessage(api: any MessageCenterServing, item: MessageItem) async throws {
if let msgId = item.msgId {
try await api.messageDelete(id: msgId)
}
messages.removeAll { $0.id == item.id }
}
///
func mapMessages(_ source: [MessageEntity]) -> [MessageItem] {
source.map { item in
MessageItem(
id: "msg_\(item.id)",
msgId: item.id,
title: item.title.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? item.typeName.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "系统通知",
detail: item.content,
time: item.pushAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? item.createdAt.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "0000-00-00 00:00",
isRead: item.isRead,
type: Self.messageType(from: item.type)
)
}
}
///
static func messageType(from value: Int) -> MessageType {
switch value {
case 1:
return .order
case 2:
return .writeOff
default:
return .system
}
}
private func deduplicatedAndSorted(_ source: [MessageItem]) -> [MessageItem] {
var unique: [String: MessageItem] = [:]
for item in source {
unique[item.id] = item
}
return unique.values.sorted { $0.time > $1.time }
}
private func clearMessages() {
messages = []
hasMoreMessages = false
lastId = 0
isLoadingMore = false
}
}
private extension String {
var nonEmpty: String? {
isEmpty ? nil : self
}
}