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, dayLabel } from './date.js' 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 HabitView({ date, onDateChange }) { const [data, setData] = useState(null) // { today, date, items, summary } const [err, setErr] = useState('') const [bursts, setBursts] = useState({}) // { habitId: 触发计数 } const [form, setForm] = useState(EMPTY_FORM) const [showForm, setShowForm] = useState(false) const load = useCallback((silent) => { if (!silent) setErr('') checkinApi.habits(date) .then((d) => setData(d)) .catch((e) => { if (!silent) setErr(e.message) }) }, [date]) useEffect(() => { load(false) }, [load]) useRefreshOnVisible(() => load(true)) const items = useMemo(() => { const list = data?.items || [] // 未完成在前, 已完成在后 (F-CK-16 默认排序) return [...list].sort((a, b) => Number(a.completed) - Number(b.completed) || a.id - b.id) }, [data]) const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 })) // 乐观更新: 先本地 +1, 成功用服务端结果覆盖, 失败回滚 (F-SY-06/E-02) async function doCheck(item) { const before = data const patch = (it) => { const done = it.done + 1 return { ...it, done, remaining: Math.max(0, it.target - done), percent: Math.min(1, done / it.target), completed: done >= it.target, over: Math.max(0, done - it.target), countOnDate: (it.countOnDate || 0) + 1, dayDone: (it.countOnDate || 0) + 1 >= it.expectedPerDay, } } setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? patch(x) : x)) })) try { const res = await checkinApi.check(item.id, date) setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) if (res.item.completed) fireBurst(item.id) } catch (e) { setData(before) Toast.show({ content: e.message || '打卡失败' }) } } async function doUncheck(item) { const before = data setData((d) => ({ ...d, items: d.items.map((x) => { if (x.id !== item.id) return x const done = Math.max(0, x.done - 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: Math.max(0, (x.countOnDate || 0) - 1), } }), })) try { const res = await checkinApi.uncheck(item.id, date) setData((d) => ({ ...d, items: d.items.map((x) => (x.id === item.id ? res.item : x)) })) } catch (e) { setData(before) Toast.show({ content: e.message || '撤销失败' }) } } 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) load(true) } catch (e) { Toast.show({ content: e.message || '保存失败' }) } } async function removeHabit(item) { const ok = await Dialog.confirm({ content: `归档「${item.name}」?归档后不再出现在待办,历史记录保留。`, confirmText: '归档', cancelText: '取消', }) if (!ok) return try { await checkinApi.deleteHabit(item.id) Toast.show({ content: '已归档' }) load(true) } catch (e) { Toast.show({ content: e.message || '操作失败' }) } } const summary = data?.summary || { total: 0, done: 0 } const isToday = data ? data.date === data.today : true return (
{dayLabel(date, data?.today || toDateStr())}
已完成 {summary.done} / {summary.total} {!isToday && 非今天}
{err && (
{err} load(false)}>重试
)} {data === null && !err &&
加载中...
} {data !== null && !err && items.length === 0 && (
)}
{items.map((item) => (
doCheck(item)} />
{item.name}
{item.periodLabel} {item.target} 次 {item.streak > 0 && 连续 {item.streak}}
{item.note &&
{item.note}
}
{item.periodStart === item.periodEnd ? '今日' : `${item.periodStart.slice(5)} ~ ${item.periodEnd.slice(5)}`} {item.over > 0 && 超额 {item.over}}
{item.done > 0 && ( doUncheck(item)}>撤销 )} { setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) setShowForm(true) }} >编辑 removeHabit(item)}>归档
))}
{/* 新建 / 编辑 */} 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 }))} />
) }