// 打卡 · 日历与打卡合并视图 (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 (