import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, DotLoading, Empty, Segmented, Toast } from 'antd-mobile' import { Celebrate } from '../../components/RingProgress.jsx' import { checkinApi } from '../../api/checkin.js' import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js' import { toDateStr, addDays, addMonths, monthGrid, weekdayLabels, dayLabel, fmtMonth, } from './date.js' // 日历视图: 月历网格 + 今天/昨天/明天 三视图 + 日期标记 (F-CL-*) export default function CalendarView({ date, onDateChange }) { const today = toDateStr() const [month, setMonth] = useState(() => date.slice(0, 7)) const [cal, setCal] = useState(null) // 月历数据 const [detail, setDetail] = useState(null) // 选中日详情 const [err, setErr] = useState('') const [bursts, setBursts] = useState({}) const loadCal = useCallback((silent) => { if (!silent) setErr('') checkinApi.calendar(month) .then(setCal) .catch((e) => { if (!silent) setErr(e.message) }) }, [month]) const loadDetail = useCallback((silent) => { checkinApi.day(date).then(setDetail).catch(() => {}) }, [date]) useEffect(() => { loadCal(false) }, [loadCal]) useEffect(() => { setDetail(null); loadDetail(false) }, [loadDetail]) useRefreshOnVisible(() => { loadCal(true); loadDetail(true) }) // 切换日期时, 若跨月则同步月份 useEffect(() => { const m = date.slice(0, 7) setMonth((cur) => (cur === m ? cur : m)) }, [date]) const days = useMemo(() => monthGrid(month), [month]) const byDate = useMemo(() => { const map = new Map() ;(cal?.days || []).forEach((d) => map.set(d.date, d)) return map }, [cal]) const quickValue = useMemo(() => { if (date === today) return 0 if (date === addDays(today, -1)) return -1 if (date === addDays(today, 1)) return 1 return null }, [date, today]) async function checkFromCalendar(item) { if (!item.canCheck) return const snapshot = detail setDetail((d) => ({ ...d, items: d.items.map((x) => { if (x.id !== item.id) return x const done = x.done + 1 const countOnDate = (x.countOnDate || 0) + 1 return { ...x, done, countOnDate, percent: Math.min(1, done / x.target), completed: done >= x.target, over: Math.max(0, done - x.target), dayDone: countOnDate >= x.expectedPerDay, } }), })) try { const res = await checkinApi.check(item.id, date) const fresh = await checkinApi.day(date) setDetail(fresh) if (res.item.completed) setBursts((m) => ({ ...m, [item.id]: (m[item.id] || 0) + 1 })) loadCal(true) } catch (e) { setDetail(snapshot) Toast.show({ content: e.message || '打卡失败' }) } } const items = detail?.items || [] const isFuture = !!detail?.isFuture return (
setMonth((m) => addMonths(m, -1))}>‹ {fmtMonth(month)} setMonth((m) => addMonths(m, 1))}>› onDateChange(today)}>回到今天
onDateChange(addDays(today, Number(v)))} />
{err &&
{err} loadCal(false)}>重试
}
{weekdayLabels().map((w) => {w})}
{days.map((d) => { const info = byDate.get(d) const inMonth = d.slice(0, 7) === month const status = info?.status || 'empty' return (
onDateChange(d)} > {Number(d.slice(8, 10))} {inMonth && status !== 'empty' && ( )}
) })}
{cal && (
本月完成率 {Math.round((cal.rate || 0) * 100)}%
)}
{dayLabel(date, today)} · {date} {isFuture && 计划(不可打卡)}
{detail === null &&
加载中...
} {detail !== null && items.length === 0 && } {items.map((item) => (
{item.name} {item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay} {item.canCheck ? ( ) : ( {item.dayDone ? '计划' : '待办'} )}
))}
) }