新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
74
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
74
suixinkan/Features/MessageCenter/API/MessageCenterAPI.swift
Normal file
@ -0,0 +1,74 @@
|
||||
//
|
||||
// MessageCenterAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 消息中心服务协议,定义消息列表、已读和删除能力。
|
||||
@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
|
||||
@Observable
|
||||
/// 消息中心 API,封装消息相关网络请求。
|
||||
final class MessageCenterAPI {
|
||||
@ObservationIgnored 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 {}
|
||||
18
suixinkan/Features/MessageCenter/MessageCenter.md
Normal file
18
suixinkan/Features/MessageCenter/MessageCenter.md
Normal 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 能力,逐条调用已读接口;任一失败时保留原状态并透出错误。
|
||||
- 首屏刷新失败会清空旧数据和分页状态,加载更多失败保留当前列表。
|
||||
@ -0,0 +1,226 @@
|
||||
//
|
||||
// MessageCenterModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// 消息中心列表响应,使用 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: Color {
|
||||
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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,170 @@
|
||||
//
|
||||
// MessageCenterViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 消息中心 ViewModel,负责消息分页、筛选、已读和删除。
|
||||
final class MessageCenterViewModel {
|
||||
var messages: [MessageItem] = []
|
||||
var selectedFilter: MessageFilter = .all
|
||||
var isLoading = false
|
||||
var isLoadingMore = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var message: String?
|
||||
|
||||
private(set) var hasMoreMessages = false
|
||||
private(set) var lastId = 0
|
||||
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
|
||||
}
|
||||
}
|
||||
302
suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift
Normal file
302
suixinkan/Features/MessageCenter/Views/MessageCenterViews.swift
Normal file
@ -0,0 +1,302 @@
|
||||
//
|
||||
// MessageCenterViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 消息中心页面,展示消息列表、筛选、已读和删除入口。
|
||||
struct MessageCenterView: View {
|
||||
@Environment(MessageCenterAPI.self) private var messageAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = MessageCenterViewModel()
|
||||
@State private var selectedMessage: MessageItem?
|
||||
@State private var processingMessageId: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
content
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.navigationTitle("消息中心")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("全部已读") {
|
||||
Task { await markAllAsRead() }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.disabled(viewModel.unreadCount == 0 || viewModel.messages.isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedMessage) { item in
|
||||
MessageDetailView(item: item) { deleteItem in
|
||||
await deleteMessage(deleteItem)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
if viewModel.messages.isEmpty {
|
||||
await viewModel.reloadFirstPage(api: messageAPI)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.reloadFirstPage(api: messageAPI)
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.medium) {
|
||||
HStack(spacing: AppMetrics.Spacing.medium) {
|
||||
messageMetric(title: "消息总数", value: "\(viewModel.messages.count)", color: AppDesign.primary)
|
||||
messageMetric(title: "未读消息", value: "\(viewModel.unreadCount)", color: AppDesign.warning)
|
||||
}
|
||||
|
||||
Picker("消息筛选", selection: Binding(
|
||||
get: { viewModel.selectedFilter },
|
||||
set: { filter in
|
||||
Task { await viewModel.selectFilter(filter, api: messageAPI) }
|
||||
}
|
||||
)) {
|
||||
ForEach(MessageFilter.allCases) { filter in
|
||||
Text(filter.title).tag(filter)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Text(viewModel.filterDescription)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if viewModel.isLoading && viewModel.messages.isEmpty {
|
||||
ProgressView("加载中...")
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.loadFailed {
|
||||
ContentUnavailableView {
|
||||
Label("消息加载失败", systemImage: "exclamationmark.triangle")
|
||||
} description: {
|
||||
Text(viewModel.loadFailureReason ?? "请稍后重试")
|
||||
} actions: {
|
||||
Button("重新加载") {
|
||||
Task { await viewModel.reloadFirstPage(api: messageAPI) }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if viewModel.messages.isEmpty {
|
||||
ContentUnavailableView("暂无消息", systemImage: "bell", description: Text("暂无可查看的系统消息"))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.medium) {
|
||||
ForEach(viewModel.messages) { item in
|
||||
Button {
|
||||
Task { await openMessage(item) }
|
||||
} label: {
|
||||
MessageRow(item: item, processing: processingMessageId == item.id)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onAppear {
|
||||
if item.id == viewModel.messages.last?.id {
|
||||
Task { await viewModel.loadMore(api: messageAPI) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.isLoadingMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.medium)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func messageMetric(title: String, value: String, color: Color) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
|
||||
private func openMessage(_ item: MessageItem) async {
|
||||
processingMessageId = item.id
|
||||
defer { processingMessageId = nil }
|
||||
do {
|
||||
try await viewModel.markAsRead(api: messageAPI, item: item)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
selectedMessage = viewModel.messages.first(where: { $0.id == item.id }) ?? item
|
||||
}
|
||||
|
||||
private func markAllAsRead() async {
|
||||
do {
|
||||
try await viewModel.markAllAsRead(api: messageAPI)
|
||||
toastCenter.show("已全部标记为已读")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func deleteMessage(_ item: MessageItem) async -> Bool {
|
||||
processingMessageId = item.id
|
||||
defer { processingMessageId = nil }
|
||||
do {
|
||||
try await viewModel.deleteMessage(api: messageAPI, item: item)
|
||||
toastCenter.show("消息已删除")
|
||||
return true
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageRow: View {
|
||||
let item: MessageItem
|
||||
let processing: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.medium) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(item.type.color.opacity(0.12))
|
||||
Image(systemName: item.type.iconName)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(item.type.color)
|
||||
}
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(item.title)
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
if !item.isRead {
|
||||
Circle()
|
||||
.fill(Color(hex: 0xEF4444))
|
||||
.frame(width: 7, height: 7)
|
||||
}
|
||||
Spacer()
|
||||
Text(item.type.title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(item.type.color)
|
||||
}
|
||||
|
||||
Text(item.detail.isEmpty ? "暂无消息内容" : item.detail)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(item.time)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(Color(hex: 0x9CA3AF))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card)
|
||||
.stroke(Color(hex: item.isRead ? 0xECEFF3 : 0xBFD7FF), lineWidth: 1)
|
||||
}
|
||||
.opacity(processing ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MessageDetailView: View {
|
||||
let item: MessageItem
|
||||
let onDelete: (MessageItem) async -> Bool
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showDeleteConfirm = false
|
||||
@State private var deleting = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.large) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(item.type.color.opacity(0.12))
|
||||
Image(systemName: item.type.iconName)
|
||||
.font(.system(size: 38, weight: .bold))
|
||||
.foregroundStyle(item.type.color)
|
||||
}
|
||||
.frame(width: 82, height: 82)
|
||||
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Text(item.title)
|
||||
.font(.system(size: AppMetrics.FontSize.title2, weight: .bold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(item.time)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
|
||||
Text(item.detail.isEmpty ? "暂无消息内容" : item.detail)
|
||||
.font(.system(size: AppMetrics.FontSize.body))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineSpacing(6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.large)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.pageHorizontal)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.navigationTitle("消息详情")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("关闭") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirm = true
|
||||
} label: {
|
||||
if deleting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
}
|
||||
.disabled(deleting || item.msgId == nil)
|
||||
}
|
||||
}
|
||||
.alert("确认删除", isPresented: $showDeleteConfirm) {
|
||||
Button("取消", role: .cancel) {}
|
||||
Button("删除", role: .destructive) {
|
||||
Task {
|
||||
deleting = true
|
||||
let success = await onDelete(item)
|
||||
deleting = false
|
||||
if success { dismiss() }
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("删除后不可恢复,是否继续?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user