Files
iboard/src/pages/checkin/MergedView.jsx
T
jif2.zhang 7e229ecc84 打卡页: 「点环即打卡」提示换成新建按钮, 去掉底部歪掉的按钮
问题:
- 当日栏右侧的「点环即打卡」是纯文字提示, 看着像可点但实际无交互, 容易误导
- 分组列表底部另有一个「+ 新建打卡项」按钮, 带内联 margin,
  与卡片左右边距不一致, 视觉上是歪的

改动:
- 当日栏右侧改为「+ 新建」按钮 (antd-mobile Button, mini) 右对齐,
  复用原有 openCreate, 一个入口完成新建
- 删除底部「+ 新建打卡项」按钮及其 .mv-add-wrap 样式
- .mv-daybar 移除废弃的 .sum 样式, 新增 .mv-new-btn (margin-left:auto 右对齐)

空态「创建第一个打卡项」保留 (无任何打卡项时的引导)。
2026-10-24 08:40:09 +08:00

415 lines
15 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 打卡 · 日历与打卡合并视图 (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 (
<div className="mv-view">
{/* 月历 */}
<div className="mv-cal">
<div className="mv-month">
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, -1))}>‹</span>
<b>{fmtMonth(month)}</b>
<span className="arrow" onClick={() => setMonth((m) => addMonths(m, 1))}>›</span>
<span className="today" onClick={() => onDateChange(today)}>回到今天</span>
</div>
<div className="mv-grid mv-wd">
{weekdayLabels().map((w) => <span key={w}>{w}</span>)}
</div>
<div className="mv-grid">
{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={
'mv-day' + (inMonth ? '' : ' out') + (d === date ? ' sel' : '') +
(info?.isToday ? ' today' : '') + (info?.isFuture ? ' future' : '')
}
onClick={() => onDateChange(d)}
>
<span>{Number(d.slice(8, 10))}</span>
{inMonth && status !== 'empty' && <i className={status} />}
</div>
)
})}
</div>
{cal && (
<div className="mv-rate">
<span>本月完成率</span>
<b>{Math.round((cal.rate || 0) * 100)}%</b>
</div>
)}
</div>
{err && <div className="error-banner">{err}<span className="retry-link" onClick={() => { loadCal(false); loadDetail(false) }}>重试</span></div>}
{/* 当日栏: 右侧放「+ 新建」, 替代原来无实际作用的「点环即打卡」提示 */}
<div className="mv-daybar">
{dayLabel(date, detail?.today || today)}
{isFuture
? <span className="badge">计划(不可打卡)</span>
: <span className="badge">已完成 {summary.done} / {summary.total}</span>}
<Button
className="mv-new-btn"
size="mini"
color="primary"
onClick={openCreate}
>
+ 新建
</Button>
</div>
{detail === null && !err && <div className="monitor-loading"><DotLoading /> 加载中...</div>}
{detail !== null && items.length === 0 && (
<div className="checkin-empty">
<Empty description="当天没有打卡项" />
<Button color="primary" size="small" onClick={openCreate}>创建第一个打卡项</Button>
</div>
)}
{/* 分组列表 */}
<div className="mv-groups">
{groups.map((g) => (
<div key={g.key}>
<div className="mv-ghead">{g.label}<span className="cnt">{g.items.length} 项</span></div>
{g.items.map((item) => (
<div key={item.id} className={'mv-card' + (item.completed ? ' done' : '')}>
<RingProgress
size={42}
stroke={5}
percent={item.percent}
done={item.done}
target={item.target}
over={item.over}
completed={item.completed}
color={item.color}
onClick={() => doCheck(item)}
/>
<div className="mv-main">
<div className="mv-name">{item.name}</div>
<div className="mv-meta">
{item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
{item.streak > 0 && <span className="st">连续 {item.streak}</span>}
</div>
</div>
{isFuture ? (
<div className="mv-btn grey">计划</div>
) : (
<div
className={'mv-btn' + (item.completed ? ' grey' : '')}
onClick={() => doCheck(item)}
>
{item.completed ? '再打一次' : '打卡'}
</div>
)}
<div className="mv-more" onClick={() => setSheetItem(item)}>⋯</div>
<Celebrate fire={bursts[item.id] || 0} color={item.color} />
</div>
))}
</div>
))}
</div>
{/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
<Popup
visible={!!sheetItem}
onMaskClick={() => setSheetItem(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
>
{sheetItem && (
<div className="mv-sheet">
<div className="sheet-title">{sheetItem.name}</div>
<div
className={'mv-sheet-row' + (sheetItem.done > 0 ? '' : ' disabled')}
onClick={() => { if (sheetItem.done > 0) { doUncheck(sheetItem); setSheetItem(null) } }}
>
<span>撤销上次打卡</span>
<span className="k">今日 {sheetItem.countOnDate || 0} 次</span>
</div>
<div className="mv-sheet-row" onClick={() => openEdit(sheetItem)}>
<span>编辑打卡项</span>
<span className="k">名称 / 周期 / 目标</span>
</div>
<div className="mv-sheet-row danger" onClick={() => doArchive(sheetItem)}>
<span>归档</span>
<span className="k">历史记录保留</span>
</div>
</div>
)}
</Popup>
{/* 新建 / 编辑表单 */}
<Popup
visible={showForm}
onMaskClick={() => setShowForm(false)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, paddingBottom: 16 }}
>
<div className="form-popup">
<div className="form-title">{form.id ? '编辑打卡项' : '新建打卡项'}</div>
<div className="form-field">
<label>名称</label>
<Input
placeholder="例如:喝水 / 健身 / 读书"
value={form.name}
maxLength={20}
onChange={(v) => setForm((f) => ({ ...f, name: v }))}
/>
</div>
<div className="form-field">
<label>周期</label>
<Selector
options={PERIOD_OPTIONS}
value={[form.period]}
onChange={(v) => setForm((f) => ({ ...f, period: v[0] }))}
/>
</div>
<div className="form-field">
<label>目标次数(周期内)</label>
<Stepper min={1} max={999} value={form.target} onChange={(v) => setForm((f) => ({ ...f, target: v }))} />
</div>
<div className="form-field">
<label>颜色</label>
<div className="color-row">
{COLORS.map((c) => (
<span
key={c}
className={'color-dot' + (form.color === c ? ' active' : '')}
style={{ background: c }}
onClick={() => setForm((f) => ({ ...f, color: c }))}
/>
))}
</div>
</div>
<div className="form-field">
<label>备注(可选)</label>
<Input
placeholder="补充说明"
value={form.note}
onChange={(v) => setForm((f) => ({ ...f, note: v }))}
/>
</div>
<div className="form-actions">
<Button block color="primary" onClick={submitForm}>保存</Button>
<Button block fill="outline" onClick={() => setShowForm(false)} style={{ marginTop: 8 }}>取消</Button>
</div>
</div>
</Popup>
</div>
)
}