// 打卡 · 日历与打卡合并视图 (V0.2.1 界面调整, 设计文档 §11.5) // - 上: 月历 (月份切换 + 回到今天), 去掉「昨天/今天/明天」分段 // - 下: 当日打卡项, 按 每天 / 每周 / 每月 分组 (去掉「每年」分组) // - 卡片紧凑化: 内边距 8px, 环形 42px/环宽 5, 间距 7px // - 撤销 / 编辑 / 归档 收进「⋯」底部面板 // - 未来日期只读, 按钮灰显「计划」 import { useCallback, useEffect, useMemo, useState } from 'react' import { Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, } from 'antd-mobile' import RingProgress, { 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' // 分组顺序: 每天 → 每周 → 每月 (去掉每年) const GROUP_ORDER = ['day', 'week', 'month'] const GROUP_LABEL = { day: '每天', week: '每周', month: '每月' } const PERIOD_OPTIONS = [ { label: '每天', value: 'day' }, { label: '每周', value: 'week' }, { label: '每月', value: 'month' }, { label: '每年', value: 'year' }, ] const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#ff3141', '#a855f7', '#14b8a6', '#f43f9e'] const EMPTY_FORM = { id: null, name: '', period: 'day', target: 1, color: COLORS[0], note: '' } export default function MergedView({ date, onDateChange }) { const today = toDateStr() const [month, setMonth] = useState(() => date.slice(0, 7)) const [cal, setCal] = useState(null) // 月历数据 { days, rate } const [detail, setDetail] = useState(null) // 选中日详情 { today, date, items, summary, isFuture } const [err, setErr] = useState('') const [bursts, setBursts] = useState({}) const [sheetItem, setSheetItem] = useState(null) // 「⋯」面板对应的打卡项 const [form, setForm] = useState(EMPTY_FORM) const [showForm, setShowForm] = useState(false) // ---------- 数据加载 ---------- const loadCal = useCallback((silent) => { if (!silent) setErr('') checkinApi.calendar(month) .then(setCal) .catch((e) => { if (!silent) setErr(e.message) }) }, [month]) const loadDetail = useCallback((silent) => { if (!silent) setErr('') checkinApi.habits(date) .then(setDetail) .catch((e) => { if (!silent) setErr(e.message) }) }, [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 items = detail?.items || [] const isFuture = !!detail?.isFuture // 按周期分组: 每天 → 每周 → 每月. "每年" 不在合并视图中展示, 但若存在 // 历史 year 项则归入"每月"之外的单列, 避免数据凭空消失. const groups = useMemo(() => { const bucket = new Map() for (const it of items) { const key = GROUP_ORDER.includes(it.period) ? it.period : 'other' if (!bucket.has(key)) bucket.set(key, []) bucket.get(key).push(it) } // 组内排序: 未完成在前, 已完成在后 (沿用 F-CK-16 默认口径) for (const arr of bucket.values()) { arr.sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) } const order = [...GROUP_ORDER, 'other'] return order.filter((k) => bucket.has(k)).map((k) => ({ key: k, label: GROUP_LABEL[k] || '其它周期', items: bucket.get(k), })) }, [items]) const summary = detail?.summary || { total: 0, done: 0 } const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) // ---------- 打卡 / 撤销 (乐观更新) ---------- async function doCheck(item) { if (!item.canCheck || isFuture) return const before = 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, remaining: Math.max(0, x.target - done), percent: Math.min(1, done / x.target), completed: done >= x.target, over: Math.max(0, done - x.target), countOnDate, dayDone: countOnDate >= x.expectedPerDay, } }), })) try { const res = await checkinApi.check(item.id, date) const fresh = await checkinApi.habits(date) setDetail(fresh) if (res.item?.completed) fireBurst(item.id) loadCal(true) } catch (e) { setDetail(before) Toast.show({ content: e.message || '打卡失败' }) } } async function doUncheck(item) { const before = detail setDetail((d) => ({ ...d, items: d.items.map((x) => { if (x.id !== item.id) return x const done = Math.max(0, x.done - 1) const countOnDate = Math.max(0, (x.countOnDate || 0) - 1) return { ...x, done, remaining: Math.max(0, x.target - done), percent: Math.min(1, done / x.target), completed: done >= x.target, over: Math.max(0, done - x.target), countOnDate, dayDone: countOnDate >= x.expectedPerDay, } }), })) try { await checkinApi.uncheck(item.id, date) const fresh = await checkinApi.habits(date) setDetail(fresh) loadCal(true) } catch (e) { setDetail(before) Toast.show({ content: e.message || '撤销失败' }) } } // ---------- 新建 / 编辑 / 归档 ---------- function openCreate() { setForm(EMPTY_FORM) setShowForm(true) } function openEdit(item) { setSheetItem(null) setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) setShowForm(true) } async function submitForm() { const name = String(form.name || '').trim() if (!name) return Toast.show({ content: '请填写名称' }) if (name.length > 20) return Toast.show({ content: '名称不能超过 20 个字' }) const payload = { name, period: form.period, target: Number(form.target) || 1, color: form.color, note: form.note } try { if (form.id) await checkinApi.updateHabit(form.id, payload) else await checkinApi.createHabit(payload) setShowForm(false) setForm(EMPTY_FORM) loadDetail(true) loadCal(true) } catch (e) { Toast.show({ content: e.message || '保存失败' }) } } async function doArchive(item) { setSheetItem(null) const ok = await Dialog.confirm({ content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, confirmText: '归档', cancelText: '取消', }) if (!ok) return try { await checkinApi.deleteHabit(item.id) Toast.show({ content: '已归档' }) loadDetail(true) loadCal(true) } catch (e) { Toast.show({ content: e.message || '操作失败' }) } } // ---------- 渲染 ---------- return (
{/* 月历 */}
setMonth((m) => addMonths(m, -1))}>‹ {fmtMonth(month)} setMonth((m) => addMonths(m, 1))}>› onDateChange(today)}>回到今天
{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)}%
)}
{err &&
{err} { loadCal(false); loadDetail(false) }}>重试
} {/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */}
{dayLabel(date, detail?.today || today)} {isFuture ? 计划(不可打卡) : 已完成 {summary.done} / {summary.total}}
{detail === null && !err &&
加载中...
} {detail !== null && items.length === 0 && (
)} {/* 分组列表 */}
{groups.map((g) => (
{g.label}{g.items.length} 项
{g.items.map((item) => (
doCheck(item)} />
{item.name}
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay} {item.streak > 0 && 连续 {item.streak}}
{isFuture ? (
计划
) : (
doCheck(item)} > {item.completed ? '再打一次' : '打卡'}
)}
setSheetItem(item)}>⋯
))}
))}
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */} setSheetItem(null)} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} > {sheetItem && (
{sheetItem.name}
0 ? '' : ' disabled')} onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }} > 撤销上次打卡 今日 {sheetItem.countOnDate || 0} 次
openEdit(sheetItem)}> 编辑打卡项 名称 / 周期 / 目标
doArchive(sheetItem)}> 归档 历史记录保留
)}
{/* 新建 / 编辑表单 */} setShowForm(false)} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }} >
{form.id ? '编辑打卡项' : '新建打卡项'}
setForm((f) => ({ ...f, name: v }))} />
setForm((f) => ({ ...f, period: v[0] }))} />
setForm((f) => ({ ...f, target: v }))} />
{COLORS.map((c) => ( setForm((f) => ({ ...f, color: c }))} /> ))}
setForm((f) => ({ ...f, note: v }))} />
) }