From 330e6585d90ae783e9c74ec50a9d238ab91d6a0d Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sat, 24 Oct 2026 08:30:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(V0.2):=20=E6=89=93=E5=8D=A1=E4=B8=8E?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E5=90=88=E5=B9=B6=E4=B8=BA=E5=8D=95=E8=A7=86?= =?UTF-8?q?=E5=9B=BE,=20=E6=89=93=E5=8D=A1=E5=86=85=E5=AE=B9=E6=8C=89=20?= =?UTF-8?q?=E6=AF=8F=E5=A4=A9/=E6=AF=8F=E5=91=A8/=E6=AF=8F=E6=9C=88=20?= =?UTF-8?q?=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现设计文档 §11.5「日历与打卡合并」待确认项 (界面设计.html 2.12 ②). 结构: - CheckinPage 由 3 个 tab 收敛为 2 个: 打卡(含日历) / 清单 - 新增 MergedView.jsx: 上为月历, 下为当日打卡项 (原 CalendarView + HabitView 的展示/交互合并到一个组件) 按规格落地: - 月历: 月份切换 + 回到今天; 去掉「昨天/今天/明天」分段; 日期格 38→31px - 分组: 每天 → 每周 → 每月, 组标题左侧 3px 主色竖条 + 右侧项数; 去掉「每年」分组 (若存在历史 year 项则归入"其它周期"分组, 避免数据凭空消失) - 卡片紧凑化: 内边距 12→8px, 环形 58→42px (环宽 6→5), 间距 10→7px - 操作: 撤销 / 编辑 / 归档 由卡片底部三个文字链接收进「⋯」底部面板 - 联动: 点日期即切换当日数据; 未来日期只读, 按钮灰显「计划」 - 组内仍按「未完成在前、已完成在后」排序 (F-CK-16) 样式: globals.css 新增 .mv-* 段 (月历/分组/紧凑卡片/操作面板), 数值与设计稿一致. 服务端无需改动: habits 接口已返回 period/periodLabel/expectedPerDay/canCheck 等字段. --- src/pages/checkin/CheckinPage.jsx | 13 +- src/pages/checkin/MergedView.jsx | 415 ++++++++++++++++++++++++++++++ src/styles/globals.css | 69 +++++ 3 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 src/pages/checkin/MergedView.jsx 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; }