import SwiftUI struct CalendarMonthGrid: View { let monthAnchor: Date let data: CalendarData let selectedDate: Date? let onTapDay: (Date) -> Void init(monthAnchor: Date, data: CalendarData, selectedDate: Date? = nil, onTapDay: @escaping (Date) -> Void) { self.monthAnchor = monthAnchor self.data = data self.selectedDate = selectedDate self.onTapDay = onTapDay } private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 // 周一开始 c.locale = Locale.current return c }() private let weekdayLabels = [ String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), String(appLoc: "日") ] private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) private var days: [DayCell] { guard let monthInterval = calendar.dateInterval(of: .month, for: monthAnchor) else { return [] } let firstOfMonth = monthInterval.start let weekdayIndex = (calendar.component(.weekday, from: firstOfMonth) - calendar.firstWeekday + 7) % 7 let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30 var cells: [DayCell] = [] // leading padding (上月尾) for offset in (0.. today RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(backgroundFill) // 选中描边 if isSelected { RoundedRectangle(cornerRadius: 6, style: .continuous) .strokeBorder(Tj.Palette.brick, lineWidth: 1.5) } VStack(spacing: 2) { Text("\(dayNumber)") .font(.system(size: 13, weight: (isToday || isSelected) ? .bold : .regular, design: .default)) .foregroundStyle(textColor) .padding(.top, 4) // 症状连续条 if !ranges.isEmpty { VStack(spacing: 1) { ForEach(Array(ranges.prefix(2).enumerated()), id: \.element.id) { _, range in symptomBar(range) } if ranges.count > 2 { Text("+\(ranges.count - 2)") .font(.system(size: 7, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } } // 异常 / 普通点 if marks.hasAnyEvent { HStack(spacing: 2) { if marks.abnormalCount > 0 { Circle().fill(Tj.Palette.brick).frame(width: 4, height: 4) } if marks.reportCount > 0 { Circle().fill(Tj.Palette.ink2).frame(width: 4, height: 4) } if marks.normalCount > 0 && marks.abnormalCount == 0 { Circle().fill(Tj.Palette.leaf).frame(width: 4, height: 4) } if marks.diaryCount > 0 { Circle().fill(Tj.Palette.text3.opacity(0.7)).frame(width: 4, height: 4) } } } Spacer(minLength: 0) } } .frame(height: 56) .contentShape(Rectangle()) } private var textColor: Color { if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) } if isSelected { return Tj.Palette.brick } if isToday { return Tj.Palette.ink } return Tj.Palette.text } private var backgroundFill: Color { if isSelected { return Tj.Palette.brickSoft.opacity(0.5) } if isToday { return Tj.Palette.sand2 } return .clear } private func symptomBar(_ range: SymptomRange) -> some View { let pos = range.position(cell.date, calendar: calendar) let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0 let trailingRadius: CGFloat = (pos == .end || pos == .single) ? 3 : 0 return GeometryReader { geo in UnevenRoundedRectangle( topLeadingRadius: leadingRadius, bottomLeadingRadius: leadingRadius, bottomTrailingRadius: trailingRadius, topTrailingRadius: trailingRadius, style: .continuous ) .fill(range.color) .frame( width: barWidth(for: pos, in: geo.size.width), height: 4 ) .frame(maxWidth: .infinity, alignment: barAlignment(for: pos)) } .frame(height: 4) } private func barWidth(for pos: SymptomRange.Position, in cellWidth: CGFloat) -> CGFloat { switch pos { case .single: return cellWidth - 8 case .start, .end: return cellWidth - 2 case .middle: return cellWidth + 4 // 越界让相邻天视觉连接 } } private func barAlignment(for pos: SymptomRange.Position) -> Alignment { switch pos { case .start: return .leading case .end: return .trailing case .single: return .center case .middle: return .center } } }