ios-projects/AIStudyApp/AIStudyApp/Features/Profile/NotificationListView.swift
wangdl 4ebb70c036 feat: 图标线型化 + 首页重设计 + 知识库卡片优化 + 知识点列表重构
- 所有 SF Symbol .fill 图标替换为线性版本
- 自定义加载动画全部替换为原生 ProgressView/refreshable
- StudyHomeView 重设计:优先级驱动主行动卡片
- ZLibraryCard 重新设计:封面图自适应、信息布局优化
- LibraryDetailPage:顶部KB信息区、···菜单、排序、长按操作
- 知识点列表:文件类型图标、学习时长、分割线样式
- 弥散渐变顶部背景
- 新增 icon-folder、icon-xmark SVG

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 20:07:15 +08:00

157 lines
6.7 KiB
Swift

import SwiftUI
struct NotificationListView: View {
@State private var notifications: [NotificationItem] = []
@State private var isLoading = false
@State private var isRefreshing = false
private var todayItems: [NotificationItem] {
notifications.filter { isToday($0.createdAt) }
}
private var weekItems: [NotificationItem] {
notifications.filter { !isToday($0.createdAt) && isThisWeek($0.createdAt) }
}
private var olderItems: [NotificationItem] {
notifications.filter { !isThisWeek($0.createdAt) }
}
var body: some View {
ZStack {
Color.zxBg0.ignoresSafeArea()
ScrollView {
VStack(spacing: 0) {
if isLoading && notifications.isEmpty {
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04) }.padding(.top, 120)
} else if notifications.isEmpty {
VStack(spacing: 12) { Image("icon-bell-off").font(.system(size: 40)).foregroundColor(Color.zxF03); Text("暂无通知").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF03) }.padding(.top, 120)
} else {
if !todayItems.isEmpty { sectionView("今天", items: todayItems) }
if !weekItems.isEmpty { sectionView("本周", items: weekItems) }
if !olderItems.isEmpty { sectionView("更早", items: olderItems) }
}
}.padding(.bottom, 100)
}
.scrollIndicators(.hidden)
.refreshable { await refresh() }
}
.navigationTitle("消息中心")
.navigationBarTitleDisplayMode(.inline).toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task {
let _ = try? await NotificationService.shared.markAllRead()
await loadNotifications()
}
} label: {
Text("全部已读").font(.system(size: 14))
}
}
}
.task { await loadNotifications() }
}
private func sectionView(_ title: String, items: [NotificationItem]) -> some View {
VStack(alignment: .leading, spacing: 0) {
Text(title).font(.system(size: 12, weight: .semibold)).foregroundColor(Color.zxF035).padding(.horizontal, 20).padding(.top, 16).padding(.bottom, 8)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.offset) { i, n in
ZXNotificationItemRow(item: n) {
Task { _ = try? await NotificationService.shared.markRead(id: n.id) }
}
if i < items.count - 1 { ZXSettingDivider() }
}
}
.background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.zxBorder006, lineWidth: 1))
.padding(.horizontal, 20)
}
}
private func loadNotifications() async {
isLoading = true
do { notifications = try await NotificationService.shared.list() } catch {}
isLoading = false
}
private func refresh() async {
isRefreshing = true
do { notifications = try await NotificationService.shared.list() } catch {}
isRefreshing = false
}
private func isToday(_ dateStr: String?) -> Bool {
guard let d = dateStr else { return false }
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
let today = f.string(from: Date())
return d.prefix(10) == today
}
private func isThisWeek(_ dateStr: String?) -> Bool {
guard let d = dateStr else { return false }
// Backend returns ISO 8601 or MySQL datetime
let clean = d.prefix(19).replacingOccurrences(of: "T", with: " ")
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd HH:mm:ss"
guard let date = f.date(from: String(clean)) else { return false }
return Date().timeIntervalSince(date) < 7 * 86400
}
}
struct ZXNotificationItemRow: View {
let item: NotificationItem
let onTap: () -> Void
private var iconName: String {
switch item.type {
case "review", "review_reminder": return "arrow.triangle.2.circlepath"
case "import_complete": return "doc.fill.badge.ellipsis"
case "import_failed": return "doc.fill.badge.xmark"
case "quiz_ready": return "questionmark.circle"
case "ai_analysis", "ai_complete": return "brain.head.profile"
case "streak": return "flame"
case "subscription", "subscription_update": return "bell"
case "system": return "info.circle"
default: return "bell"
}
}
private var iconColor: Color {
switch item.type {
case "review", "review_reminder": return Color.zxOrange
case "ai_analysis", "ai_complete": return Color.zxPurple
case "streak": return Color.zxGreen
case "import_failed": return Color.zxCoral
default: return Color.zxAccent
}
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: iconName).font(.system(size: 16)).foregroundColor(iconColor)
.frame(width: 36, height: 36).background(iconColor.opacity(0.12)).clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(item.title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0)
if item.readAt == nil { Circle().fill(Color.zxPurple).frame(width: 6, height: 6) }
}
if let content = item.content { Text(content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2) }
if let createdAt = item.createdAt {
Text(formatDate(createdAt)).font(.system(size: 10)).foregroundColor(Color.zxF03)
}
}
Spacer()
}.padding(.horizontal, 16).padding(.vertical, 14)
}.foregroundColor(.primary)
}
private func formatDate(_ iso: String) -> String {
if let d = ISO8601DateFormatter().date(from: String(iso.prefix(19)) + "Z") {
let f = DateFormatter(); f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "MM-dd HH:mm"; return f.string(from: d)
}
return String(iso.prefix(10))
}
}