feat(V0.2): 打卡 / 日历 / 清单模块
- 数据: 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)与接口清单
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user