From 9467d4c242b9e850bd4098239951ea965c453912 Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sun, 25 Oct 2026 00:06:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(V0.2):=20=E5=88=97=E8=A1=A8=E6=8B=96?= =?UTF-8?q?=E5=8A=A8=E4=BF=AE=E5=A4=8D=20+=20=E6=89=93=E5=8D=A1=E9=A1=B9?= =?UTF-8?q?=E7=BB=84=E5=86=85=E6=8B=96=E5=8A=A8=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 任务列表/看板拖动: 激活后用非 passive touchmove preventDefault 拦截浏览器滚动接管, 修复列表长按抬起后 pointercancel 导致无法真正拖动 - 打卡: 列表按周期分组展示(每天/每周/每月/每年), 长按拖动组内排序(上下落点指示), 新增 POST /checkin/habits/reorder 重写 sortOrder; 新建项排到末尾 - 打卡卡片加 touch-action/user-select 防长按误触; 拖动结束 350ms 内抑制点击余波 --- server/routes/checkin.js | 673 +++++++++++++------------ src/api/checkin.js | 83 +-- src/components/checkin/useHabitDrag.js | 97 ++++ src/components/task/useColumnDrag.js | 4 + src/components/task/useRowDrag.js | 6 + src/pages/checkin/HabitView.jsx | 620 ++++++++++++----------- src/styles/globals.css | 15 + 7 files changed, 847 insertions(+), 651 deletions(-) create mode 100644 src/components/checkin/useHabitDrag.js diff --git a/server/routes/checkin.js b/server/routes/checkin.js index 2c41ccb..5a28016 100644 --- a/server/routes/checkin.js +++ b/server/routes/checkin.js @@ -1,328 +1,345 @@ -import { Router } from 'express' -import { prisma } from '../db.js' -import { authRequired } from '../middleware.js' -import { - toDateStr, addDays, periodRange, periodDays, expectedPerDay, - periodIsClosed, eachDay, PERIODS, PERIOD_LABEL, -} from '../checkin/period.js' - -const router = Router() -router.use(authRequired) - -// 补打卡允许的回溯天数 (F-CK-10) -const BACKFILL_DAYS = 7 - -const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ -const MONTH_RE = /^\d{4}-\d{2}$/ - -function normDate(v) { - const s = String(v || '') - return DATE_RE.test(s) ? s : toDateStr() -} - -// ---------- 统计辅助 ---------- - -// 记录 -> { habitId: { date: count } } -function groupRecords(records) { - const map = new Map() - for (const r of records) { - if (!map.has(r.habitId)) map.set(r.habitId, new Map()) - const byDate = map.get(r.habitId) - byDate.set(r.date, (byDate.get(r.date) || 0) + 1) - } - return map -} - -function countInRange(byDate, start, end) { - let n = 0 - eachDay(start, end, (d) => { n += byDate.get(d) || 0 }) - return n -} - -// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断 -function computeStreak(habit, byDate, today) { - let cursor = today - let streak = 0 - for (let i = 0; i < 400; i++) { - const { start } = periodRange(habit.period, cursor) - const done = countInRange(byDate, start, periodRange(habit.period, cursor).end) - if (done >= habit.target) { - streak++ - } else if (periodIsClosed(habit.period, cursor, today)) { - break - } - const prevEnd = addDays(start, -1) - if (prevEnd < '1970-01-01') break - cursor = prevEnd - } - return streak -} - -async function loadActiveHabits(userId) { - return prisma.habit.findMany({ - where: { userId, archived: false }, - orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], - }) -} - -// 组装单个打卡项的展示态 -function buildItem(habit, byDate, date, today) { - const { start, end } = periodRange(habit.period, date) - const done = countInRange(byDate, start, end) - const target = Math.max(1, habit.target) - const onDate = byDate.get(date) || 0 - const expPerDay = expectedPerDay(habit.period, target, date) - return { - id: habit.id, - name: habit.name, - period: habit.period, - periodLabel: PERIOD_LABEL[habit.period] || '每天', - target, - color: habit.color, - icon: habit.icon, - note: habit.note, - archived: habit.archived, - periodStart: start, - periodEnd: end, - periodDays: periodDays(habit.period, date), - done, - remaining: Math.max(0, target - done), - percent: Math.min(1, target > 0 ? done / target : 1), - completed: done >= target, - over: Math.max(0, done - target), - countOnDate: onDate, - expectedPerDay: expPerDay, - dayDone: onDate >= expPerDay, - streak: computeStreak(habit, byDate, today), - } -} - -// 拉取 [start, end] 内的打卡记录并分组 -async function loadRecords(userId, habits, ranges) { - let min = ranges[0]?.start - let max = ranges[0]?.end - for (const r of ranges) { - if (r.start < min) min = r.start - if (r.end > max) max = r.end - } - if (!min || !max) return new Map() - const records = await prisma.checkIn.findMany({ - where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } }, - select: { habitId: true, date: true }, - }) - return groupRecords(records) -} - -// ---------- 打卡项 ---------- - -// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度 -router.get('/habits', async (req, res, next) => { - try { - const userId = req.user.sub - const today = toDateStr() - const date = normDate(req.query.date) - const habits = await loadActiveHabits(userId) - const ranges = habits.map((h) => periodRange(h.period, date)) - const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) - const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today)) - const doneCount = items.filter((i) => i.completed).length - res.json({ today, date, items, summary: { total: items.length, done: doneCount } }) - } catch (e) { next(e) } -}) - -// POST /api/checkin/habits 新建打卡项 -router.post('/habits', async (req, res, next) => { - try { - const userId = req.user.sub - const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {} - const cleanName = String(name || '').trim() - if (!cleanName) return res.status(400).json({ error: '名称不能为空' }) - if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) - if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) - const t = Math.floor(Number(target)) - if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) - const row = await prisma.habit.create({ - data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || '') }, - }) - res.json({ item: row }) - } catch (e) { next(e) } -}) - -// PUT /api/checkin/habits/:id 编辑 -router.put('/habits/:id', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const exist = await prisma.habit.findFirst({ where: { id, userId } }) - if (!exist) return res.status(404).json({ error: '打卡项不存在' }) - const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {} - const data = {} - if (name !== undefined) { - const n = String(name).trim() - if (!n) return res.status(400).json({ error: '名称不能为空' }) - if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) - data.name = n - } - if (period !== undefined) { - if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) - data.period = period - } - if (target !== undefined) { - const t = Math.floor(Number(target)) - if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) - data.target = t - } - if (color !== undefined) data.color = String(color) - if (icon !== undefined) data.icon = String(icon) - if (note !== undefined) data.note = String(note) - if (archived !== undefined) data.archived = !!archived - if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0 - const row = await prisma.habit.update({ where: { id }, data }) - res.json({ item: row }) - } catch (e) { next(e) } -}) - -// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除 -router.delete('/habits/:id', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const exist = await prisma.habit.findFirst({ where: { id, userId } }) - if (!exist) return res.status(404).json({ error: '打卡项不存在' }) - if (req.query.hard === '1') { - await prisma.checkIn.deleteMany({ where: { habitId: id } }) - await prisma.habit.delete({ where: { id } }) - return res.json({ ok: true, removed: true }) - } - const row = await prisma.habit.update({ where: { id }, data: { archived: true } }) - res.json({ ok: true, removed: false, item: row }) - } catch (e) { next(e) } -}) - -// ---------- 打卡 / 撤销 ---------- - -// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡 -router.post('/habits/:id/check', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const habit = await prisma.habit.findFirst({ where: { id, userId } }) - if (!habit) return res.status(404).json({ error: '打卡项不存在' }) - const today = toDateStr() - const date = normDate((req.body || {}).date) - if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' }) - if (date < addDays(today, -BACKFILL_DAYS)) { - return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` }) - } - const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } }) - const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1 - const note = String((req.body || {}).note || '').slice(0, 200) - await prisma.checkIn.create({ - data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' }, - }) - const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)]) - res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) - } catch (e) { - // 唯一约束冲突 = 并发重复提交, 视为已打卡 - if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' }) - next(e) - } -}) - -// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡 -router.delete('/habits/:id/check', async (req, res, next) => { - try { - const userId = req.user.sub - const id = Number(req.params.id) - const habit = await prisma.habit.findFirst({ where: { id, userId } }) - if (!habit) return res.status(404).json({ error: '打卡项不存在' }) - const today = toDateStr() - const date = normDate(req.query.date) - const { start, end } = periodRange(habit.period, date) - const last = await prisma.checkIn.findFirst({ - where: { habitId: id, date: { gte: start, lte: end } }, - orderBy: [{ date: 'desc' }, { seq: 'desc' }], - }) - if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' }) - await prisma.checkIn.delete({ where: { id: last.id } }) - const grouped = await loadRecords(userId, [habit], [{ start, end }]) - res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) - } catch (e) { next(e) } -}) - -// ---------- 某日详情 / 月历 ---------- - -// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记) -router.get('/day', async (req, res, next) => { - try { - const userId = req.user.sub - const today = toDateStr() - const date = normDate(req.query.date) - const habits = await loadActiveHabits(userId) - const ranges = habits.map((h) => periodRange(h.period, date)) - const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) - const items = habits.map((h) => { - const base = buildItem(h, grouped.get(h.id) || new Map(), date, today) - return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) } - }) - res.json({ today, date, isToday: date === today, isFuture: date > today, items }) - } catch (e) { next(e) } -}) - -// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率 -router.get('/calendar', async (req, res, next) => { - try { - const userId = req.user.sub - const today = toDateStr() - const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7) - const start = month + '-01' - const end = addDays(monthStartNext(month), -1) - const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) - const active = habits.filter((h) => !h.archived) - const records = await prisma.checkIn.findMany({ - where: { userId, date: { gte: start, lte: end } }, - select: { habitId: true, date: true }, - }) - const grouped = groupRecords(records) - const days = [] - let expTotal = 0 - let doneTotal = 0 - eachDay(start, end, (d) => { - let expected = 0 - let actual = 0 - let over = false - const detail = [] - for (const h of active) { - if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望 - const exp = expectedPerDay(h.period, Math.max(1, h.target), d) - const cnt = (grouped.get(h.id) || new Map()).get(d) || 0 - expected += exp - actual += Math.min(cnt, exp) - if (cnt > exp) over = true - detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp }) - } - const isFuture = d > today - if (!isFuture) { expTotal += expected; doneTotal += actual } - let status = 'empty' - if (expected > 0 && actual >= expected) status = over ? 'over' : 'full' - else if (actual > 0) status = 'partial' - days.push({ - date: d, expected, actual, status, isToday: d === today, isFuture, - habits: detail, - }) - }) - res.json({ - today, month, days, - rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0, - habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })), - }) - } catch (e) { next(e) } -}) - -function monthStartNext(month) { - const [y, m] = month.split('-').map(Number) - const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月) - return d.toISOString().slice(0, 10) -} - -export default router +import { Router } from 'express' +import { prisma } from '../db.js' +import { authRequired } from '../middleware.js' +import { + toDateStr, addDays, periodRange, periodDays, expectedPerDay, + periodIsClosed, eachDay, PERIODS, PERIOD_LABEL, +} from '../checkin/period.js' + +const router = Router() +router.use(authRequired) + +// 补打卡允许的回溯天数 (F-CK-10) +const BACKFILL_DAYS = 7 + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const MONTH_RE = /^\d{4}-\d{2}$/ + +function normDate(v) { + const s = String(v || '') + return DATE_RE.test(s) ? s : toDateStr() +} + +// ---------- 统计辅助 ---------- + +// 记录 -> { habitId: { date: count } } +function groupRecords(records) { + const map = new Map() + for (const r of records) { + if (!map.has(r.habitId)) map.set(r.habitId, new Map()) + const byDate = map.get(r.habitId) + byDate.set(r.date, (byDate.get(r.date) || 0) + 1) + } + return map +} + +function countInRange(byDate, start, end) { + let n = 0 + eachDay(start, end, (d) => { n += byDate.get(d) || 0 }) + return n +} + +// 连续达标的周期数: 当前周期未结束且未达标 -> 跳过(不算断签), 不是中断 +function computeStreak(habit, byDate, today) { + let cursor = today + let streak = 0 + for (let i = 0; i < 400; i++) { + const { start } = periodRange(habit.period, cursor) + const done = countInRange(byDate, start, periodRange(habit.period, cursor).end) + if (done >= habit.target) { + streak++ + } else if (periodIsClosed(habit.period, cursor, today)) { + break + } + const prevEnd = addDays(start, -1) + if (prevEnd < '1970-01-01') break + cursor = prevEnd + } + return streak +} + +async function loadActiveHabits(userId) { + return prisma.habit.findMany({ + where: { userId, archived: false }, + orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], + }) +} + +// 组装单个打卡项的展示态 +function buildItem(habit, byDate, date, today) { + const { start, end } = periodRange(habit.period, date) + const done = countInRange(byDate, start, end) + const target = Math.max(1, habit.target) + const onDate = byDate.get(date) || 0 + const expPerDay = expectedPerDay(habit.period, target, date) + return { + id: habit.id, + name: habit.name, + period: habit.period, + sortOrder: habit.sortOrder, + periodLabel: PERIOD_LABEL[habit.period] || '每天', + target, + color: habit.color, + icon: habit.icon, + note: habit.note, + archived: habit.archived, + periodStart: start, + periodEnd: end, + periodDays: periodDays(habit.period, date), + done, + remaining: Math.max(0, target - done), + percent: Math.min(1, target > 0 ? done / target : 1), + completed: done >= target, + over: Math.max(0, done - target), + countOnDate: onDate, + expectedPerDay: expPerDay, + dayDone: onDate >= expPerDay, + streak: computeStreak(habit, byDate, today), + } +} + +// 拉取 [start, end] 内的打卡记录并分组 +async function loadRecords(userId, habits, ranges) { + let min = ranges[0]?.start + let max = ranges[0]?.end + for (const r of ranges) { + if (r.start < min) min = r.start + if (r.end > max) max = r.end + } + if (!min || !max) return new Map() + const records = await prisma.checkIn.findMany({ + where: { userId, habitId: { in: habits.map((h) => h.id) }, date: { gte: min, lte: max } }, + select: { habitId: true, date: true }, + }) + return groupRecords(records) +} + +// ---------- 打卡项 ---------- + +// GET /api/checkin/habits?date=YYYY-MM-DD 今日(或指定日)待办 + 周期进度 +router.get('/habits', async (req, res, next) => { + try { + const userId = req.user.sub + const today = toDateStr() + const date = normDate(req.query.date) + const habits = await loadActiveHabits(userId) + const ranges = habits.map((h) => periodRange(h.period, date)) + const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) + const items = habits.map((h) => buildItem(h, grouped.get(h.id) || new Map(), date, today)) + const doneCount = items.filter((i) => i.completed).length + res.json({ today, date, items, summary: { total: items.length, done: doneCount } }) + } catch (e) { next(e) } +}) + +// POST /api/checkin/habits 新建打卡项 +router.post('/habits', async (req, res, next) => { + try { + const userId = req.user.sub + const { name, period = 'day', target = 1, color = '#1677ff', icon = '', note = '' } = req.body || {} + const cleanName = String(name || '').trim() + if (!cleanName) return res.status(400).json({ error: '名称不能为空' }) + if (cleanName.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) + if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) + const t = Math.floor(Number(target)) + if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) + const last = await prisma.habit.findFirst({ where: { userId }, orderBy: { sortOrder: 'desc' }, select: { sortOrder: true } }) + const row = await prisma.habit.create({ + data: { userId, name: cleanName, period, target: t, color: String(color), icon: String(icon), note: String(note || ''), sortOrder: (last?.sortOrder ?? -1) + 1 }, + }) + res.json({ item: row }) + } catch (e) { next(e) } +}) + +// PUT /api/checkin/habits/:id 编辑 +router.put('/habits/:id', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const exist = await prisma.habit.findFirst({ where: { id, userId } }) + if (!exist) return res.status(404).json({ error: '打卡项不存在' }) + const { name, period, target, color, icon, note, archived, sortOrder } = req.body || {} + const data = {} + if (name !== undefined) { + const n = String(name).trim() + if (!n) return res.status(400).json({ error: '名称不能为空' }) + if (n.length > 20) return res.status(400).json({ error: '名称不能超过 20 个字' }) + data.name = n + } + if (period !== undefined) { + if (!PERIODS.includes(period)) return res.status(400).json({ error: '周期类型不合法' }) + data.period = period + } + if (target !== undefined) { + const t = Math.floor(Number(target)) + if (!Number.isFinite(t) || t < 1) return res.status(400).json({ error: '目标次数需为 ≥1 的整数' }) + data.target = t + } + if (color !== undefined) data.color = String(color) + if (icon !== undefined) data.icon = String(icon) + if (note !== undefined) data.note = String(note) + if (archived !== undefined) data.archived = !!archived + if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0 + const row = await prisma.habit.update({ where: { id }, data }) + res.json({ item: row }) + } catch (e) { next(e) } +}) + +// POST /api/checkin/habits/reorder { ids: [习惯id 的新顺序] } 按数组下标重写 sortOrder +router.post('/habits/reorder', async (req, res, next) => { + try { + const userId = req.user.sub + const ids = Array.isArray((req.body || {}).ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [] + if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' }) + const habits = await prisma.habit.findMany({ where: { userId, id: { in: ids } } }) + if (habits.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的打卡项' }) + await prisma.$transaction( + ids.map((id, i) => prisma.habit.update({ where: { id }, data: { sortOrder: i } })), + ) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + +// DELETE /api/checkin/habits/:id?hard=1 默认归档(保留历史), hard=1 物理删除 +router.delete('/habits/:id', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const exist = await prisma.habit.findFirst({ where: { id, userId } }) + if (!exist) return res.status(404).json({ error: '打卡项不存在' }) + if (req.query.hard === '1') { + await prisma.checkIn.deleteMany({ where: { habitId: id } }) + await prisma.habit.delete({ where: { id } }) + return res.json({ ok: true, removed: true }) + } + const row = await prisma.habit.update({ where: { id }, data: { archived: true } }) + res.json({ ok: true, removed: false, item: row }) + } catch (e) { next(e) } +}) + +// ---------- 打卡 / 撤销 ---------- + +// POST /api/checkin/habits/:id/check { date?, note? } 打一次卡 +router.post('/habits/:id/check', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const habit = await prisma.habit.findFirst({ where: { id, userId } }) + if (!habit) return res.status(404).json({ error: '打卡项不存在' }) + const today = toDateStr() + const date = normDate((req.body || {}).date) + if (date > today) return res.status(400).json({ error: '不能为未来日期打卡' }) + if (date < addDays(today, -BACKFILL_DAYS)) { + return res.status(400).json({ error: `最多只能补打最近 ${BACKFILL_DAYS} 天` }) + } + const dayRecords = await prisma.checkIn.findMany({ where: { habitId: id, date }, select: { seq: true } }) + const seq = dayRecords.reduce((m, r) => Math.max(m, r.seq), 0) + 1 + const note = String((req.body || {}).note || '').slice(0, 200) + await prisma.checkIn.create({ + data: { habitId: id, userId, date, seq, note, source: date === today ? 'user' : 'backfill' }, + }) + const grouped = await loadRecords(userId, [habit], [periodRange(habit.period, date)]) + res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) + } catch (e) { + // 唯一约束冲突 = 并发重复提交, 视为已打卡 + if (e && e.code === 'P2002') return res.status(409).json({ error: '该次打卡已记录' }) + next(e) + } +}) + +// DELETE /api/checkin/habits/:id/check?date= 撤销本周期内最近一次打卡 +router.delete('/habits/:id/check', async (req, res, next) => { + try { + const userId = req.user.sub + const id = Number(req.params.id) + const habit = await prisma.habit.findFirst({ where: { id, userId } }) + if (!habit) return res.status(404).json({ error: '打卡项不存在' }) + const today = toDateStr() + const date = normDate(req.query.date) + const { start, end } = periodRange(habit.period, date) + const last = await prisma.checkIn.findFirst({ + where: { habitId: id, date: { gte: start, lte: end } }, + orderBy: [{ date: 'desc' }, { seq: 'desc' }], + }) + if (!last) return res.status(400).json({ error: '本周期还没有打卡记录' }) + await prisma.checkIn.delete({ where: { id: last.id } }) + const grouped = await loadRecords(userId, [habit], [{ start, end }]) + res.json({ item: buildItem(habit, grouped.get(habit.id) || new Map(), date, today), date }) + } catch (e) { next(e) } +}) + +// ---------- 某日详情 / 月历 ---------- + +// GET /api/checkin/day?date=YYYY-MM-DD 当天每个打卡项的明细 (含可打卡/只读标记) +router.get('/day', async (req, res, next) => { + try { + const userId = req.user.sub + const today = toDateStr() + const date = normDate(req.query.date) + const habits = await loadActiveHabits(userId) + const ranges = habits.map((h) => periodRange(h.period, date)) + const grouped = await loadRecords(userId, habits, ranges.length ? ranges : [{ start: date, end: date }]) + const items = habits.map((h) => { + const base = buildItem(h, grouped.get(h.id) || new Map(), date, today) + return { ...base, canCheck: date <= today && date >= addDays(today, -BACKFILL_DAYS) } + }) + res.json({ today, date, isToday: date === today, isFuture: date > today, items }) + } catch (e) { next(e) } +}) + +// GET /api/checkin/calendar?month=YYYY-MM 月历: 每天完成情况 + 当月完成率 +router.get('/calendar', async (req, res, next) => { + try { + const userId = req.user.sub + const today = toDateStr() + const month = MONTH_RE.test(String(req.query.month || '')) ? String(req.query.month) : today.slice(0, 7) + const start = month + '-01' + const end = addDays(monthStartNext(month), -1) + const habits = await prisma.habit.findMany({ where: { userId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] }) + const active = habits.filter((h) => !h.archived) + const records = await prisma.checkIn.findMany({ + where: { userId, date: { gte: start, lte: end } }, + select: { habitId: true, date: true }, + }) + const grouped = groupRecords(records) + const days = [] + let expTotal = 0 + let doneTotal = 0 + eachDay(start, end, (d) => { + let expected = 0 + let actual = 0 + let over = false + const detail = [] + for (const h of active) { + if (toDateStr(h.createdAt) > d) continue // 创建之前的日期不计入期望 + const exp = expectedPerDay(h.period, Math.max(1, h.target), d) + const cnt = (grouped.get(h.id) || new Map()).get(d) || 0 + expected += exp + actual += Math.min(cnt, exp) + if (cnt > exp) over = true + detail.push({ id: h.id, name: h.name, color: h.color, count: cnt, expected: exp, done: cnt >= exp }) + } + const isFuture = d > today + if (!isFuture) { expTotal += expected; doneTotal += actual } + let status = 'empty' + if (expected > 0 && actual >= expected) status = over ? 'over' : 'full' + else if (actual > 0) status = 'partial' + days.push({ + date: d, expected, actual, status, isToday: d === today, isFuture, + habits: detail, + }) + }) + res.json({ + today, month, days, + rate: expTotal > 0 ? Math.min(1, doneTotal / expTotal) : 0, + habits: active.map((h) => ({ id: h.id, name: h.name, color: h.color, period: h.period, target: h.target })), + }) + } catch (e) { next(e) } +}) + +function monthStartNext(month) { + const [y, m] = month.split('-').map(Number) + const d = new Date(Date.UTC(y, m, 1)) // m 已 +1 (下月) + return d.toISOString().slice(0, 10) +} + +export default router diff --git a/src/api/checkin.js b/src/api/checkin.js index e31c020..aca18c8 100644 --- a/src/api/checkin.js +++ b/src/api/checkin.js @@ -1,41 +1,42 @@ -import { api } from './client.js' - -const q = (params) => { - const s = new URLSearchParams() - Object.entries(params || {}).forEach(([k, v]) => { - if (v !== undefined && v !== null && v !== '') s.set(k, v) - }) - const str = s.toString() - return str ? '?' + str : '' -} - -// 打卡 / 日历 -export const checkinApi = { - habits: (date) => api('/checkin/habits' + q({ date })), - day: (date) => api('/checkin/day' + q({ date })), - calendar: (month) => api('/checkin/calendar' + q({ month })), - createHabit: (body) => api('/checkin/habits', { method: 'POST', body }), - updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }), - deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }), - check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }), - uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }), -} - -// 检查清单 -export const checklistApi = { - lists: (archived) => api('/checklists' + q({ archived: archived ? 1 : 0 })), - list: (id) => api(`/checklists/${id}`), - create: (body) => api('/checklists', { method: 'POST', body }), - update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }), - remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }), - duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }), - addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }), - updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }), - removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }), - checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }), - templates: () => api('/checklists/templates'), - template: (id) => api(`/checklists/templates/${id}`), - createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }), - updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }), - removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }), -} +import { api } from './client.js' + +const q = (params) => { + const s = new URLSearchParams() + Object.entries(params || {}).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') s.set(k, v) + }) + const str = s.toString() + return str ? '?' + str : '' +} + +// 打卡 / 日历 +export const checkinApi = { + habits: (date) => api('/checkin/habits' + q({ date })), + day: (date) => api('/checkin/day' + q({ date })), + calendar: (month) => api('/checkin/calendar' + q({ month })), + createHabit: (body) => api('/checkin/habits', { method: 'POST', body }), + updateHabit: (id, body) => api(`/checkin/habits/${id}`, { method: 'PUT', body }), + reorderHabits: (ids) => api('/checkin/habits/reorder', { method: 'POST', body: { ids } }), + deleteHabit: (id, hard) => api(`/checkin/habits/${id}${hard ? '?hard=1' : ''}`, { method: 'DELETE' }), + check: (id, date, note) => api(`/checkin/habits/${id}/check`, { method: 'POST', body: { date, note } }), + uncheck: (id, date) => api(`/checkin/habits/${id}/check` + q({ date }), { method: 'DELETE' }), +} + +// 检查清单 +export const checklistApi = { + lists: (archived) => api('/checklists' + q({ archived: archived ? 1 : 0 })), + list: (id) => api(`/checklists/${id}`), + create: (body) => api('/checklists', { method: 'POST', body }), + update: (id, body) => api(`/checklists/${id}`, { method: 'PUT', body }), + remove: (id) => api(`/checklists/${id}`, { method: 'DELETE' }), + duplicate: (id) => api(`/checklists/${id}/duplicate`, { method: 'POST', body: {} }), + addItem: (id, body) => api(`/checklists/${id}/items`, { method: 'POST', body }), + updateItem: (itemId, body) => api(`/checklists/items/${itemId}`, { method: 'PUT', body }), + removeItem: (itemId) => api(`/checklists/items/${itemId}`, { method: 'DELETE' }), + checkAll: (id, done) => api(`/checklists/${id}/check-all`, { method: 'POST', body: { done } }), + templates: () => api('/checklists/templates'), + template: (id) => api(`/checklists/templates/${id}`), + createTemplate: (body) => api('/checklists/templates', { method: 'POST', body }), + updateTemplate: (id, body) => api(`/checklists/templates/${id}`, { method: 'PUT', body }), + removeTemplate: (id) => api(`/checklists/templates/${id}`, { method: 'DELETE' }), +} diff --git a/src/components/checkin/useHabitDrag.js b/src/components/checkin/useHabitDrag.js new file mode 100644 index 0000000..141f248 --- /dev/null +++ b/src/components/checkin/useHabitDrag.js @@ -0,0 +1,97 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +// 打卡项组内拖动排序 +// 手势与任务列表/看板一致: 长按 400ms 原位抬起(震动), 之后拖动; +// elementFromPoint 命中同一周期组内的 [data-habit-row] 作为落点, +// 按指针在目标卡上半/下半决定插到其前/后, 松手提交。 +// 只允许组内 (每天/每周/每月/每年) 排序, 命中其他组视为无效落点。 +// 激活后用非 passive touchmove preventDefault 拦截浏览器滚动接管, +// 保证纵向拖动期间指针事件持续派发 (同 useRowDrag 的教训)。 +export default function useHabitDrag({ onDrop }) { + const [dragItem, setDragItem] = useState(null) // 拖动中的打卡项 (视觉态) + const [over, setOver] = useState(null) // { id, after } 落点 + const st = useRef({ timer: null, active: false, startX: 0, startY: 0, item: null, over: null }) + const lastEnd = useRef(0) + + const cleanup = useCallback(() => { + if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } + st.current.active = false + st.current.over = null + setDragItem(null) + setOver(null) + }, []) + + useEffect(() => () => cleanup(), [cleanup]) + + /** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误触打卡 */ + const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, []) + + const hitRow = (x, y) => { + const el = document.elementFromPoint(x, y) + const rowEl = el?.closest?.('[data-habit-row]') + if (!rowEl) return null + const r = rowEl.getBoundingClientRect() + return { id: Number(rowEl.dataset.habitId), after: y > r.top + r.height / 2 } + } + + const onMove = (ev) => { + const s = st.current + if (!s.active) { + if (s.timer && (Math.abs(ev.clientX - s.startX) > 10 || Math.abs(ev.clientY - s.startY) > 10)) { + clearTimeout(s.timer); s.timer = null + } + return + } + ev.preventDefault() + const hit = hitRow(ev.clientX, ev.clientY) + // 只认同一周期组的落点 + if (hit && hit.id !== s.item.id) { s.over = hit; setOver(hit) } + else { s.over = null; setOver(null) } + } + + const onUp = () => { + const s = st.current + if (s.active && s.item) { + lastEnd.current = Date.now() + if (s.over && s.over.id !== s.item.id) onDrop?.(s.item, s.over.id, s.over.after) + } + cleanup() + } + + /** 绑定到卡片: 卡片元素需设置 touch-action: pan-y */ + const bind = useCallback((item) => ({ + onPointerDown: (ev) => { + if (ev.button !== undefined && ev.button !== 0) return + st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false } + st.current.timer = setTimeout(() => { + st.current.active = true + setDragItem(item) + if (navigator.vibrate) navigator.vibrate(15) + }, 400) + try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } + const el = ev.currentTarget + const move = (e2) => onMove(e2) + const tm = (e2) => { if (st.current.active) e2.preventDefault() } + const up = () => { onUp(); detach() } + const cancel = () => { + if (st.current.active) lastEnd.current = Date.now() + cleanup() + detach() + } + const detach = () => { + el.removeEventListener('pointermove', move) + el.removeEventListener('pointerup', up) + el.removeEventListener('pointercancel', cancel) + el.removeEventListener('touchmove', tm) + } + el.addEventListener('pointermove', move) + el.addEventListener('pointerup', up) + el.addEventListener('pointercancel', cancel) + el.addEventListener('touchmove', tm, { passive: false }) + }, + onContextMenu: (ev) => ev.preventDefault(), + // eslint-disable-next-line react-hooks/exhaustive-deps + }), []) + + return { dragItem, over, bind, cancel: cleanup, justEnded } +} diff --git a/src/components/task/useColumnDrag.js b/src/components/task/useColumnDrag.js index 320df9b..2d46973 100644 --- a/src/components/task/useColumnDrag.js +++ b/src/components/task/useColumnDrag.js @@ -99,6 +99,8 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } const el = ev.currentTarget const move = (e2) => onPointerMove(e2) + // 激活后拦截触摸滚动: 防纵向分量触发浏览器接管 (pointercancel), 同 useRowDrag + const tm = (e2) => { if (st.current.active) e2.preventDefault() } const up = (e2) => { onPointerUp(e2); detach() } const cancel = () => { if (st.current.active) lastEnd.current = Date.now() @@ -109,10 +111,12 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } el.removeEventListener('pointermove', move) el.removeEventListener('pointerup', up) el.removeEventListener('pointercancel', cancel) + el.removeEventListener('touchmove', tm) } el.addEventListener('pointermove', move) el.addEventListener('pointerup', up) el.addEventListener('pointercancel', cancel) + el.addEventListener('touchmove', tm, { passive: false }) }, // 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择) onContextMenu: (ev) => ev.preventDefault(), diff --git a/src/components/task/useRowDrag.js b/src/components/task/useRowDrag.js index 3cad767..ab0711f 100644 --- a/src/components/task/useRowDrag.js +++ b/src/components/task/useRowDrag.js @@ -74,6 +74,10 @@ export default function useRowDrag({ columns, onDrop }) { try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } const el = ev.currentTarget const move = (e2) => onMove(e2) + // 激活后拦截触摸滚动: pan-y 下纵向分量稍大浏览器即接管手势并派发 + // pointercancel (列表是整页长滚动容器, 必现), 非 passive touchmove + // preventDefault 才能真正拦住, 拖动期间指针事件得以持续派发 + const tm = (e2) => { if (st.current.active) e2.preventDefault() } const up = () => { onUp(); detach() } const cancel = () => { if (st.current.active) lastEnd.current = Date.now() @@ -84,10 +88,12 @@ export default function useRowDrag({ columns, onDrop }) { el.removeEventListener('pointermove', move) el.removeEventListener('pointerup', up) el.removeEventListener('pointercancel', cancel) + el.removeEventListener('touchmove', tm) } el.addEventListener('pointermove', move) el.addEventListener('pointerup', up) el.addEventListener('pointercancel', cancel) + el.addEventListener('touchmove', tm, { passive: false }) }, // 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择) onContextMenu: (ev) => ev.preventDefault(), diff --git a/src/pages/checkin/HabitView.jsx b/src/pages/checkin/HabitView.jsx index cc9ca08..56eeab4 100644 --- a/src/pages/checkin/HabitView.jsx +++ b/src/pages/checkin/HabitView.jsx @@ -1,282 +1,338 @@ -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 }))} - /> -
-
- - -
-
-
-
- ) -} +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, 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 || [] + // 手动排序优先 (sortOrder), 拖动排序后覆盖 F-CK-16 的"未完成在前" + return [...list].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.id - b.id) + }, [data]) + + // 按周期分组 (每天/每周/每月/每年), 组内顺序 = 手动排序 + const groups = useMemo(() => { + const out = [] + for (const p of PERIOD_OPTIONS) { + const list = items.filter((i) => i.period === p.value) + if (list.length) out.push({ key: p.value, label: p.label, list }) + } + const known = new Set(PERIOD_OPTIONS.map((p) => p.value)) + const rest = items.filter((i) => !known.has(i.period)) + if (rest.length) out.push({ key: 'other', label: '其他', list: rest }) + return out + }, [items]) + + const drag = useHabitDrag({ onDrop: reorderHabit }) + + // 组内拖动落位: 重排后按展平顺序整体重写 sortOrder + async function reorderHabit(dragged, targetId, after) { + const snapshot = data + const g = groups.find((x) => x.key === dragged.period) + if (!g) return + const rest = g.list.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.list)).flat() + const withOrder = flat.map((it, i) => ({ ...it, sortOrder: i })) + setData((d) => ({ ...d, items: withOrder })) + try { + await checkinApi.reorderHabits(flat.map((i) => i.id)) + } catch (e) { + setData(snapshot) + Toast.show({ content: e.message || '排序保存失败' }) + } + } + + 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 && ( +
+ + +
+ )} + +
+ {groups.map((g) => ( +
+
+ {g.label} + {g.list.length} +
+ {g.list.map((item) => ( +
+ { if (!drag.justEnded()) 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 && ( + { if (!drag.justEnded()) doUncheck(item) }}>撤销 + )} + { + if (drag.justEnded()) return + setForm({ id: item.id, name: item.name, period: item.period, target: item.target, color: item.color, note: item.note }) + setShowForm(true) + }} + >编辑 + { if (!drag.justEnded()) 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 }))} + /> +
+
+ + +
+
+
+
+ ) +} diff --git a/src/styles/globals.css b/src/styles/globals.css index ccc7d4f..2c6ea95 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -663,6 +663,21 @@ body.me-safe-top .app-safe-top { height: 0; } .habit-link { font-size: 12px; color: var(--color-primary); cursor: pointer; } .habit-link.danger { color: var(--color-danger); } +/* 打卡项分组 + 长按拖动排序 (组内) */ +.habit-view { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; } +.habit-view input, .habit-view textarea { -webkit-user-select: text; user-select: text; } +.habit-group-title { + display: flex; align-items: center; gap: 7px; padding: 10px 2px 4px; + font-size: 12px; color: var(--color-muted); font-weight: 600; +} +.habit-group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); } +.habit-group-title .cnt { color: #b8bcc4; font-weight: 400; } +.habit-card { touch-action: pan-y; } +.habit-card.dragging { opacity: 0.55; outline: 2px dashed var(--color-primary); outline-offset: 1px; } +.habit-card.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; } +.habit-card.dragover.after { border-bottom: 3px solid var(--color-primary); } +.habit-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; }