diff --git a/src/pages/checkin/CheckinPage.jsx b/src/pages/checkin/CheckinPage.jsx
index 8287c17..5c828eb 100644
--- a/src/pages/checkin/CheckinPage.jsx
+++ b/src/pages/checkin/CheckinPage.jsx
@@ -1,20 +1,19 @@
import { useState } from 'react'
import PageHeader from '../../components/PageHeader.jsx'
import SwipeTabs from '../../components/SwipeTabs.jsx'
-import HabitView from './HabitView.jsx'
-import CalendarView from './CalendarView.jsx'
+import MergedView from './MergedView.jsx'
import ListView from './ListView.jsx'
import { toDateStr } from './date.js'
-// 打卡页: 三个视图 —— 打卡(环形进度) / 日历(今天·昨天·明天) / 清单(检查清单 + 模板)
-// 注: 打卡与日历的合并视图正在设计确认中, 确认后此处收敛为 日历(含打卡) / 清单 两个视图
+// 打卡页: 两个视图 —— 日历(含打卡) / 清单
+// V0.2.1 界面调整 (设计文档 §11.5): 原「打卡」与「日历」两个 tab 合并为一个视图,
+// 上日历下打卡、按 每天/每周/每月 分组、去掉「昨天今天明天」分段.
export default function CheckinPage() {
- const [active, setActive] = useState('habit')
+ const [active, setActive] = useState('calendar')
const [date, setDate] = useState(() => toDateStr())
const tabs = [
- { key: 'habit', label: '打卡', render: () => },
- { key: 'calendar', label: '日历', render: () => },
+ { key: 'calendar', label: '打卡', render: () => },
{ key: 'list', label: '清单', render: () => },
]
diff --git a/src/pages/checkin/MergedView.jsx b/src/pages/checkin/MergedView.jsx
new file mode 100644
index 0000000..3833b1d
--- /dev/null
+++ b/src/pages/checkin/MergedView.jsx
@@ -0,0 +1,415 @@
+// 打卡 · 日历与打卡合并视图 (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)}>⋯
+
+
+ ))}
+
+ ))}
+
+
+ {detail !== null && (
+
+
+
+ )}
+
+ {/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */}
+
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/styles/globals.css b/src/styles/globals.css
index 79627a1..3792110 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -814,3 +814,72 @@ body.me-safe-top .app-safe-top { height: 0; }
border: 2px solid transparent; transition: transform 0.15s ease;
}
.color-dot.active { border-color: rgba(0,0,0,.25); transform: scale(1.12); }
+
+/* ================== 打卡 · 日历与打卡合并视图 (V0.2.1, 设计文档 §11.5) ================== */
+/* 上: 月历; 下: 当日打卡项, 按 每天/每周/每月 分组. 卡片紧凑化. */
+.mv-view { display: flex; flex-direction: column; min-height: 0; }
+
+/* ---- 月历 ---- */
+.mv-cal { background: #fff; border-radius: 10px; margin: 8px 12px 0; padding: 6px 6px 8px; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
+.mv-month { display: flex; align-items: center; gap: 8px; padding: 2px 4px 6px; }
+.mv-month b { font-size: 14px; }
+.mv-month .arrow { font-size: 17px; color: var(--color-muted); line-height: 1; padding: 0 3px; cursor: pointer; }
+.mv-month .today { margin-left: auto; font-size: 11px; color: var(--color-primary); cursor: pointer; }
+.mv-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
+.mv-wd span { text-align: center; font-size: 10px; color: var(--color-muted); padding: 1px 0 3px; display: block; }
+.mv-day {
+ height: 31px; margin: 1px 0; display: flex; flex-direction: column;
+ align-items: center; justify-content: center; border-radius: 6px;
+ font-size: 12px; line-height: 1.15; color: var(--color-text); cursor: pointer;
+}
+.mv-day.out { color: #c8c8c8; }
+.mv-day.future { color: #b0b0b0; }
+.mv-day.today { color: var(--color-primary); font-weight: 700; }
+.mv-day.sel { background: var(--color-primary); color: #fff; font-weight: 600; }
+.mv-day i { width: 4px; height: 4px; border-radius: 50%; margin-top: 2px; }
+.mv-day i.partial { background: #ff8f1f; }
+.mv-day i.full { background: #00b578; }
+.mv-day i.over { background: #ff6b6b; }
+.mv-day.sel i { background: #fff; }
+.mv-rate { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--color-muted); padding: 7px 6px 0; }
+.mv-rate b { color: var(--color-primary); }
+
+/* ---- 当日栏 ---- */
+.mv-daybar { display: flex; align-items: center; gap: 8px; padding: 10px 14px 2px; font-size: 13px; font-weight: 600; }
+.mv-daybar .badge { font-weight: 400; color: var(--color-muted); font-size: 11px; }
+.mv-daybar .sum { margin-left: auto; font-size: 11px; color: var(--color-muted); font-weight: 400; }
+
+/* ---- 分组 ---- */
+.mv-groups { padding-bottom: 4px; }
+.mv-ghead { display: flex; align-items: center; gap: 6px; padding: 9px 14px 5px; font-size: 11px; font-weight: 600; color: var(--color-muted); }
+.mv-ghead::before { content: ""; width: 3px; height: 11px; border-radius: 2px; background: var(--color-primary); }
+.mv-ghead .cnt { margin-left: auto; font-weight: 400; color: #b8bcc4; }
+
+/* ---- 紧凑卡片 ---- */
+.mv-card {
+ position: relative; display: flex; align-items: center; gap: 9px; background: #fff;
+ border-radius: 9px; padding: 8px 10px; margin: 0 12px 7px; box-shadow: 0 1px 2px rgba(0,0,0,.04);
+}
+.mv-card.done { background: #f7fbf8; }
+.mv-main { flex: 1; min-width: 0; }
+.mv-name { font-size: 14px; font-weight: 600; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.mv-meta { font-size: 10px; color: var(--color-muted); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.mv-meta .st { color: #ff8f1f; background: rgba(255,143,31,.12); border-radius: 3px; padding: 0 4px; margin-left: 5px; }
+.mv-btn {
+ font-size: 11px; border-radius: 999px; padding: 4px 12px; background: var(--color-primary);
+ color: #fff; flex-shrink: 0; white-space: nowrap; cursor: pointer;
+}
+.mv-btn.grey { background: #f2f3f5; color: var(--color-muted); }
+.mv-more { color: #c0c4cc; font-size: 15px; line-height: 1; padding: 4px 2px 4px 6px; flex-shrink: 0; cursor: pointer; }
+.mv-add-wrap { margin-top: 2px; }
+
+/* ---- 「⋯」操作面板 ---- */
+.mv-sheet { padding: 16px 16px 4px; }
+.mv-sheet .sheet-title { font-size: 15px; font-weight: 600; margin-bottom: 8px; }
+.mv-sheet-row {
+ display: flex; align-items: center; justify-content: space-between;
+ padding: 12px 4px; border-top: 1px solid var(--color-border); font-size: 14px; cursor: pointer;
+}
+.mv-sheet-row.danger { color: var(--color-danger); }
+.mv-sheet-row.disabled { opacity: .45; cursor: default; }
+.mv-sheet-row .k { font-size: 11px; color: #b8bcc4; }