- {/* 月历 */}
-
-
- 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 }))}
- />
-
-
-
-
-
-
-
-
- )
-}
+// 打卡 · 日历与打卡合并视图 (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 useHabitDrag from '../../components/checkin/useHabitDrag.js'
+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 项则归入"每月"之外的单列, 避免数据凭空消失.
+ // 组内顺序: 手动排序 (sortOrder) 优先 —— 拖动排序需要稳定口径,
+ // 服务端 /habits 已按 sortOrder 返回, 此处仅兜底。
+ 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)
+ }
+ for (const arr of bucket.values()) {
+ arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || 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 drag = useHabitDrag({ onDrop: reorderHabit })
+
+ // 组内拖动落位: 重排后按渲染分组顺序整体展平重写 sortOrder
+ async function reorderHabit(dragged, targetId, after) {
+ const snapshot = detail
+ const g = groups.find((x) => x.key === (GROUP_ORDER.includes(dragged.period) ? dragged.period : 'other'))
+ if (!g) return
+ const rest = g.items.filter((i) => i.id !== dragged.id)
+ const ti = rest.findIndex((i) => i.id === targetId)
+ if (ti < 0) return
+ rest.splice(after ? ti + 1 : ti, 0, dragged)
+ const flat = groups.map((x) => (x.key === g.key ? rest : x.items)).flat()
+ setDetail((d) => ({ ...d, items: flat.map((it, i) => ({ ...it, sortOrder: i })) }))
+ try {
+ await checkinApi.reorderHabits(flat.map((i) => i.id))
+ } catch (e) {
+ setDetail(snapshot)
+ Toast.show({ content: e.message || '排序保存失败' })
+ }
+ }
+
+ const summary = detail?.summary || { total: 0, done: 0 }
+ const fireBurst = (id) => setBursts((m) => ({ ...m, [id]: (m[id] || 0) + 1 }))
+
+ // ---------- 打卡 / 撤销 (乐观更新) ----------
+ async function doCheck(item) {
+ // 只按"是否未来日期"拦截. 不能判 item.canCheck —— 该字段仅 /day 接口返回,
+ // 本视图数据来自 /habits (不含 canCheck), 用它会把所有打卡按钮全部禁用.
+ if (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) => (
+
+
{ if (!drag.justEnded()) doCheck(item) }}
+ />
+
+
{item.name}
+
+ {item.periodLabel} {item.target} 次 · 当日 {item.countOnDate || 0}/{item.expectedPerDay}
+ {item.streak > 0 && 连续 {item.streak}}
+
+
+ {isFuture ? (
+ 计划
+ ) : (
+ { if (!drag.justEnded()) 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 }))}
+ />
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/pages/task/ListView.jsx b/src/pages/task/ListView.jsx
index 4b3c4d3..267f3d3 100644
--- a/src/pages/task/ListView.jsx
+++ b/src/pages/task/ListView.jsx
@@ -53,6 +53,7 @@ export default function ListView({
onClick={() => { if (!drag.justEnded()) onOpen(t) }}
onMore={onMore}
bindDrag={drag.bind}
+ bindDragHandle={drag.bindHandle}
dragTarget={dragging ? orderedCols[dragging.toIdx] : null}
/>
{kids.length > 0 && (
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 4ccf61a..ffa1b5f 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -678,6 +678,25 @@ body.me-safe-top .app-safe-top { height: 0; }
.habit-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
.habit-card.dragover.before { border-top: 3px solid var(--color-primary); }
+/* 拖动把手 (⠿): touch-action:none —— 浏览器完全不接管该触摸,
+ 指针流不会被滚动抢占取消, 是 Android WebView 上唯一可靠的拖动入口 */
+.grip {
+ touch-action: none; -webkit-user-select: none; user-select: none;
+ color: #c2c7cf; font-size: 14px; line-height: 1; padding: 8px 2px;
+ cursor: grab; flex-shrink: 0; align-self: center;
+}
+.grip:active { color: var(--color-primary); }
+.task-right .grip { margin-right: 2px; }
+
+/* MergedView 打卡卡拖动排序 */
+.mv-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
+.mv-view input, .mv-view textarea { -webkit-user-select: text; user-select: text; }
+.mv-card { touch-action: pan-y; }
+.mv-card.dragging { opacity: .55; outline: 2px dashed var(--color-primary); outline-offset: 1px; }
+.mv-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; }
+.mv-card.dragover.after { border-bottom: 3px solid var(--color-primary); }
+.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
+
/* ----- 日历视图 ----- */
.calendar-view { padding: 0 12px 20px; }
.cal-month-bar { display: flex; align-items: center; gap: 10px; padding: 8px 2px; }