import SwiftUI struct CalendarMonthGrid: View { let monthAnchor: Date let data: CalendarData let onTapDay: (Date) -> Void private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 // 周一开始 c.locale = Locale(identifier: "zh_CN") return c }() private let weekdayLabels = ["一", "二", "三", "四", "五", "六", "日"] 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.. 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 isToday { return Tj.Palette.ink } return Tj.Palette.text } 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 } } }