- 数据: Habit/CheckIn/CheckList/CheckListItem/CheckListTemplate + 迁移 + 重新生成 client
- 后端: server/checkin/period.js 周期计算(日/周/月/年, 周一起始, Asia/Shanghai)
/api/checkin/* 打卡项CRUD/打卡/撤销/补打/某日详情/月历
/api/checklists/* 清单与检查项CRUD/勾选/全选/复制/模板(系统预置出差·回老家)
- 前端: 底部新增「打卡」Tab -> /checkin, 页内三视图
环形进度 RingProgress + 完成特效(粒子/光晕/触感, 可关闭)
打卡视图: 待办列表/乐观更新/撤销/新建编辑
日历视图: 月历网格 + 今天·昨天·明天 + 日期标记/完成率
清单视图: 场景清单/勾选进度条/模板生成/另存模板/归档
- 文档: docs/V0.2/需求文档.md 补充实现约定(附录B)与接口清单
174 lines
6.2 KiB
React
174 lines
6.2 KiB
React
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 (
|
||
<div className="calendar-view">
|
||
<div className="cal-month-bar">
|
||
<span className="cal-arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
|
||
<span className="cal-month">{fmtMonth(month)}</span>
|
||
<span className="cal-arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
|
||
<span className="cal-today-btn" onClick={() => onDateChange(today)}>回到今天</span>
|
||
</div>
|
||
|
||
<div className="cal-quick">
|
||
<Segmented
|
||
options={[
|
||
{ label: '昨天', value: -1 },
|
||
{ label: '今天', value: 0 },
|
||
{ label: '明天', value: 1 },
|
||
]}
|
||
value={quickValue}
|
||
onChange={(v) => onDateChange(addDays(today, Number(v)))}
|
||
/>
|
||
</div>
|
||
|
||
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => loadCal(false)}>重试</span></div>}
|
||
|
||
<div className="cal-grid">
|
||
<div className="cal-weekday">
|
||
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
|
||
</div>
|
||
<div className="cal-days">
|
||
{days.map((d) => {
|
||
const info = byDate.get(d)
|
||
const inMonth = d.slice(0, 7) === month
|
||
const status = info?.status || 'empty'
|
||
return (
|
||
<div
|
||
key={d}
|
||
className={
|
||
'cal-day' + (inMonth ? '' : ' out') + (d === date ? ' selected' : '') +
|
||
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
|
||
}
|
||
onClick={() => onDateChange(d)}
|
||
>
|
||
<span className="cal-day-num">{Number(d.slice(8, 10))}</span>
|
||
{inMonth && status !== 'empty' && (
|
||
<span className={'cal-dot ' + status} />
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{cal && (
|
||
<div className="cal-rate">本月完成率 <strong>{Math.round((cal.rate || 0) * 100)}%</strong></div>
|
||
)}
|
||
|
||
<div className="cal-detail">
|
||
<div className="cal-detail-title">
|
||
{dayLabel(date, today)} · {date}
|
||
{isFuture && <span className="habit-badge">计划(不可打卡)</span>}
|
||
</div>
|
||
|
||
{detail === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
|
||
{detail !== null && items.length === 0 && <Empty description="当天没有打卡项" />}
|
||
|
||
{items.map((item) => (
|
||
<div key={item.id} className={'cal-item' + (item.completed ? ' done' : '')}>
|
||
<span className="cal-item-dot" style={{ background: item.color }} />
|
||
<span className="cal-item-name">{item.name}</span>
|
||
<span className="cal-item-meta">
|
||
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
|
||
</span>
|
||
<span className="cal-item-right">
|
||
{item.canCheck ? (
|
||
<Button size="mini" color={item.dayDone ? 'default' : 'primary'} onClick={() => checkFromCalendar(item)}>
|
||
{item.dayDone ? '已达标' : '打卡'}
|
||
</Button>
|
||
) : (
|
||
<span className="cal-item-plan">{item.dayDone ? '计划' : '待办'}</span>
|
||
)}
|
||
</span>
|
||
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|