// MARK: - 冰箱贴 ViewModel @MainActor final class FridgeViewModel: ObservableObject { @Published var notes: [FridgeNote] = [] private let session = URLSession(configuration: .default) struct FridgeNote: Identifiable { let id: String; let text: String; let role: String; let createdAt: String var isMine: Bool { role == "眠眠" || role == "user" } var replies: [FridgeReply] = [] } struct FridgeReply: Identifiable { let id: String; let text: String; let role: String; let createdAt: String var isMine: Bool { role == "眠眠" || role == "user" } } func fetch() async { let req = ServerConfig.makeRequest(path: "fridge/list") do { let (data, _) = try await session.data(for: req) if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let items = obj["notes"] as? [[String: Any]] { notes = items.map { item in let replies: [FridgeReply] = (item["replies"] as? [[String: Any]] ?? []).map { r in FridgeReply(id: r["id"] as? String ?? "", text: r["text"] as? String ?? "", role: r["role"] as? String ?? "user", createdAt: r["created_at"] as? String ?? "") } return FridgeNote(id: item["id"] as? String ?? "", text: item["text"] as? String ?? "", role: item["role"] as? String ?? "user", createdAt: item["created_at"] as? String ?? "", replies: replies) } } } catch {} } func add(text: String) async { var req = ServerConfig.makeRequest(path: "fridge/add") req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["text": text, "role": "眠眠"]) _ = try? await session.data(for: req) await fetch() } func reply(noteId: String, text: String) async { var req = ServerConfig.makeRequest(path: "fridge/reply") req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["id": noteId, "text": text, "role": "眠眠"]) _ = try? await session.data(for: req) await fetch() } func delete(id: String) async { var req = ServerConfig.makeRequest(path: "fridge/delete") req.httpMethod = "POST"; req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try? JSONSerialization.data(withJSONObject: ["id": id]) _ = try? await session.data(for: req) await fetch() } } // MARK: - 冰箱贴页 struct FridgePage: View { @StateObject private var vm = FridgeViewModel() @State private var showAdd = false @State private var newText = "" @State private var selectedNote: FridgeViewModel.FridgeNote? let theme: Theme private let size: CGFloat = 42 private let gap: CGFloat = 3 private let cols: [CGFloat] = [32, 77, 122, 167, 212, 257, 302] var body: some View { VStack(spacing: 0) { Spacer() ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .bottom, spacing: 3) { ForEach((0..<7).reversed(), id: \.self) { dayIndex in let dayNotes = notesForDay(dayIndex) VStack(spacing: 3) { if dayIndex == 6 { Button { showAdd = true } label: { Rectangle() .fill(theme.fridgeAddBtn) .frame(width: size, height: size) } .buttonStyle(.plain) } ForEach(dayNotes) { note in Button { selectedNote = note } label: { Rectangle() .fill(note.isMine ? theme.fridgeMine : theme.fridgeOther) .frame(width: size, height: size) } .buttonStyle(.plain) .contextMenu { Button("删除", role: .destructive) { Task { await vm.delete(id: note.id) } } } } } } } .padding(.horizontal, 8) } .environment(\.layoutDirection, .rightToLeft) .padding(.bottom, 20) } .onAppear { Task { await vm.fetch() } } .alert("新冰箱贴(最多50字)", isPresented: $showAdd) { TextField("内容", text: $newText) Button("取消", role: .cancel) { newText = "" } Button("添加") { Task { await vm.add(text: String(newText.prefix(50))); newText = "" } } } .sheet(item: $selectedNote) { note in FridgeDetailView(noteId: note.id, vm: vm, theme: theme) } } private func notesForDay(_ dayIndex: Int) -> [FridgeViewModel.FridgeNote] { let cal = Calendar.current let today = cal.startOfDay(for: Date()) guard let targetDay = cal.date(byAdding: .day, value: -(6 - dayIndex), to: today) else { return [] } let nextDay = cal.date(byAdding: .day, value: 1, to: targetDay)! return vm.notes.filter { note in let iso = String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: " ") let fm = DateFormatter(); fm.dateFormat = "yyyy-MM-dd HH:mm:ss" guard let d = fm.date(from: iso) else { return false } return d >= targetDay && d < nextDay } } } struct FridgeNoteCard: View { let note: FridgeViewModel.FridgeNote let theme: Theme var body: some View { VStack(alignment: .leading, spacing: 4) { Text(formattedDate).font(.custom("HYPixel-11px-J", size: 18).weight(.bold)).foregroundStyle(theme.textPrimary) Text(note.text).font(.custom("HYPixel-11px-J", size: 13).weight(.bold)).foregroundStyle(note.isMine ? theme.fridgeTextMine : theme.fridgeTextOther).lineSpacing(4).lineLimit(2) if !note.replies.isEmpty { Text("\(note.replies.count) 条回复").font(.custom("HYPixel-11px-J", size: 11)).foregroundStyle(theme.textTyping) } } .padding(.horizontal, 20).padding(.vertical, 10) .frame(maxWidth: .infinity, alignment: .leading) .background(Rectangle().fill(note.isMine ? theme.fridgeMine : theme.fridgeOther)) } private var formattedDate: String { String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: "-") } } // MARK: - 冰箱贴详情 struct FridgeDetailView: View { let noteId: String @ObservedObject var vm: FridgeViewModel @Environment(\.dismiss) private var dismiss @State private var replyText = "" let theme: Theme private var note: FridgeViewModel.FridgeNote? { vm.notes.first(where: { $0.id == noteId }) } var body: some View { VStack(spacing: 0) { HStack { Text(formattedDate).font(.custom("HYPixel-11px-J", size: 16).weight(.bold)).foregroundStyle(theme.textPrimary) Spacer() Button("关闭") { dismiss() }.foregroundStyle(theme.textTyping) } .padding(.horizontal, 20).padding(.top, 20) if let note { Text(note.text) .font(.custom("HYPixel-11px-J", size: 16)) .foregroundStyle(note.isMine ? theme.fridgeTextMine : theme.fridgeTextOther) .lineSpacing(6) .frame(maxWidth: .infinity, alignment: .leading) .padding(20) .background(Rectangle().fill(note.isMine ? theme.fridgeMine : theme.fridgeOther)) .padding(.horizontal, 20).padding(.top, 12) ScrollView { VStack(spacing: 8) { ForEach(note.replies) { reply in HStack { if reply.isMine { Spacer(minLength: 40) } VStack(alignment: reply.isMine ? .trailing : .leading, spacing: 2) { Text(reply.text).font(.custom("HYPixel-11px-J", size: 16)) .foregroundStyle(reply.isMine ? theme.bubbleTextRight : theme.bubbleTextLeft) .lineSpacing(5) .padding(.horizontal, 12).padding(.vertical, 6) .background(Rectangle().fill(reply.isMine ? theme.bubbleRight : theme.bubbleLeft)) Text(replyTime(reply.createdAt)).font(.custom("HYPixel-11px-J", size: 10)).foregroundStyle(theme.textTyping) } if !reply.isMine { Spacer(minLength: 40) } } .padding(.horizontal, 20) } } .padding(.vertical, 12) } HStack(spacing: 0) { TextField("回复(最多50字)...", text: $replyText) .font(.custom("HYPixel-11px-J", size: 16)).foregroundStyle(theme.textPlaceholder) Spacer() Button { guard !replyText.isEmpty else { return } let t = String(replyText.prefix(50)); replyText = "" Task { await vm.reply(noteId: noteId, text: t) } } label: { Image(systemName: "arrowshape.turn.up.left.fill").font(.system(size: 14)).foregroundStyle(.white) .frame(width: 32, height: 32).background(Circle().fill(theme.sendBtn)) } } .padding(.horizontal, 16).frame(height: 44) .background(Rectangle().fill(theme.inputBg)) .overlay(Rectangle().stroke(theme.inputBorder, lineWidth: 1)) .padding(.horizontal, 14).padding(.bottom, 8) } } .background(theme.bg.ignoresSafeArea()) } private var formattedDate: String { guard let note else { return "" } return String(note.createdAt.prefix(19)).replacingOccurrences(of: "T", with: "-") } private func replyTime(_ iso: String) -> String { String(iso.prefix(19)).replacingOccurrences(of: "T", with: " ") } }