feat(V0.2): 打卡 / 日历 / 清单模块
- 数据: Habit/CheckIn/CheckList/CheckListItem/CheckListTemplate + 迁移 + 重新生成 client
- 后端: server/checkin/period.js 周期计算(日/周/月/年, 周一起始, Asia/Shanghai)
/api/checkin/* 打卡项CRUD/打卡/撤销/补打/某日详情/月历
/api/checklists/* 清单与检查项CRUD/勾选/全选/复制/模板(系统预置出差·回老家)
- 前端: 底部新增「打卡」Tab -> /checkin, 页内三视图
环形进度 RingProgress + 完成特效(粒子/光晕/触感, 可关闭)
打卡视图: 待办列表/乐观更新/撤销/新建编辑
日历视图: 月历网格 + 今天·昨天·明天 + 日期标记/完成率
清单视图: 场景清单/勾选进度条/模板生成/另存模板/归档
- 文档: docs/V0.2/需求文档.md 补充实现约定(附录B)与接口清单
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
// 打卡周期计算: 全部以 Asia/Shanghai 的"本地日历日"为准
|
||||
// 日期统一用 "YYYY-MM-DD" 字符串 (字典序 = 时间序, 可直接用于范围查询)
|
||||
|
||||
export const TZ = 'Asia/Shanghai'
|
||||
|
||||
const FMT = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
/** 任意时间 -> 本地日历日 "YYYY-MM-DD" */
|
||||
export function toDateStr(d = new Date()) {
|
||||
return FMT.format(d instanceof Date ? d : new Date(d))
|
||||
}
|
||||
|
||||
/** "YYYY-MM-DD" -> UTC 零点的 Date (仅用于日期运算, 不做时区展示) */
|
||||
function toUTC(s) {
|
||||
const [y, m, d] = String(s).split('-').map(Number)
|
||||
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
|
||||
}
|
||||
|
||||
function fromUTC(dt) {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function addDays(s, n) {
|
||||
const d = toUTC(s)
|
||||
d.setUTCDate(d.getUTCDate() + n)
|
||||
return fromUTC(d)
|
||||
}
|
||||
|
||||
export function addMonths(s, n) {
|
||||
const [y, m] = String(s).split('-').map(Number)
|
||||
const first = new Date(Date.UTC(y, (m || 1) - 1 + n, 1))
|
||||
const day = Number(String(s).slice(8, 10)) || 1
|
||||
const lastDay = new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth() + 1, 0)).getUTCDate()
|
||||
first.setUTCDate(Math.min(day, lastDay))
|
||||
return fromUTC(first)
|
||||
}
|
||||
|
||||
/** 周一为一周第一天 */
|
||||
export function startOfWeek(s) {
|
||||
const dow = (toUTC(s).getUTCDay() + 6) % 7 // 0=周一
|
||||
return addDays(s, -dow)
|
||||
}
|
||||
|
||||
export function endOfWeek(s) {
|
||||
return addDays(startOfWeek(s), 6)
|
||||
}
|
||||
|
||||
export function startOfMonth(s) {
|
||||
return String(s).slice(0, 8) + '01'
|
||||
}
|
||||
|
||||
export function endOfMonth(s) {
|
||||
const [y, m] = String(s).split('-').map(Number)
|
||||
return fromUTC(new Date(Date.UTC(y, m || 1, 0)))
|
||||
}
|
||||
|
||||
export function startOfYear(s) {
|
||||
return String(s).slice(0, 4) + '-01-01'
|
||||
}
|
||||
|
||||
export function endOfYear(s) {
|
||||
return String(s).slice(0, 4) + '-12-31'
|
||||
}
|
||||
|
||||
export const PERIODS = ['day', 'week', 'month', 'year']
|
||||
|
||||
export const PERIOD_LABEL = { day: '每天', week: '每周', month: '每月', year: '每年' }
|
||||
|
||||
/** 周期窗口 [start, end] (闭区间, 本地日历日) */
|
||||
export function periodRange(period, s) {
|
||||
switch (period) {
|
||||
case 'week': return { start: startOfWeek(s), end: endOfWeek(s) }
|
||||
case 'month': return { start: startOfMonth(s), end: endOfMonth(s) }
|
||||
case 'year': return { start: startOfYear(s), end: endOfYear(s) }
|
||||
default: return { start: s, end: s }
|
||||
}
|
||||
}
|
||||
|
||||
/** 周期窗口内的天数 */
|
||||
export function periodDays(period, s) {
|
||||
const { start, end } = periodRange(period, s)
|
||||
return Math.round((toUTC(end) - toUTC(start)) / 86400000) + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 日均期望次数: 用于日历上"某一天是否完成了当天应做的份额"
|
||||
* - 每天 n 次 -> n
|
||||
* - 每周 n 次 -> ceil(n / 7)
|
||||
* - 每月 n 次 -> ceil(n / 当月天数)
|
||||
* - 每年 n 次 -> ceil(n / 365)
|
||||
* 环形进度仍以"周期完成度"(周期内累计次数 / n) 为准, 两者不冲突
|
||||
*/
|
||||
export function expectedPerDay(period, target, s) {
|
||||
const n = Math.max(1, Number(target) || 1)
|
||||
if (period === 'week') return Math.max(1, Math.ceil(n / 7))
|
||||
if (period === 'month') return Math.max(1, Math.ceil(n / periodDays('month', s)))
|
||||
if (period === 'year') return Math.max(1, Math.ceil(n / 365))
|
||||
return n
|
||||
}
|
||||
|
||||
/** 该周期在日期 d 之前是否已完整结束 (用于 streak 判断: 未结束的周期不算断签) */
|
||||
export function periodIsClosed(period, d, today) {
|
||||
const { end } = periodRange(period, d)
|
||||
return end < today
|
||||
}
|
||||
|
||||
/** 遍历 [start, end] 闭区间的每一天 */
|
||||
export function eachDay(start, end, fn) {
|
||||
let cur = start
|
||||
let guard = 0
|
||||
while (cur <= end && guard++ < 4000) {
|
||||
fn(cur)
|
||||
cur = addDays(cur, 1)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import otaRouter from './routes/ota.js'
|
||||
import apizeroRouter from './routes/apizero.js'
|
||||
import monitorsRouter from './routes/monitors.js'
|
||||
import mihomeRouter from './routes/mihome.js'
|
||||
import checkinRouter from './routes/checkin.js'
|
||||
import checklistsRouter from './routes/checklists.js'
|
||||
|
||||
const PORT = Number(process.env.PORT) || 3001
|
||||
const DIST_DIR = path.join(process.cwd(), 'dist')
|
||||
@@ -36,6 +38,9 @@ app.use('/api', articlesRouter)
|
||||
app.use('/api', apizeroRouter)
|
||||
app.use('/api', monitorsRouter)
|
||||
app.use('/api/mihome', mihomeRouter)
|
||||
// V0.2 打卡 / 日历 / 清单
|
||||
app.use('/api/checkin', checkinRouter)
|
||||
app.use('/api/checklists', checklistsRouter)
|
||||
|
||||
// 静态资源 (Vite build 产物)
|
||||
if (HAS_DIST) {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
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
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Router } from 'express'
|
||||
import { prisma } from '../db.js'
|
||||
import { authRequired } from '../middleware.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(authRequired)
|
||||
|
||||
// 系统预置模板 (builtin): 首次访问模板接口时幂等写入
|
||||
const BUILTIN_TEMPLATES = [
|
||||
{
|
||||
name: '出差',
|
||||
scene: '出差',
|
||||
items: [
|
||||
{ title: '身份证 / 证件' },
|
||||
{ title: '手机充电器 + 充电宝' },
|
||||
{ title: '笔记本电脑 + 电源' },
|
||||
{ title: '耳机' },
|
||||
{ title: '换洗衣物' },
|
||||
{ title: '洗漱包' },
|
||||
{ title: '常用药' },
|
||||
{ title: '雨伞' },
|
||||
{ title: '车票 / 机票 / 酒店确认' },
|
||||
{ title: '工牌 / 名片' },
|
||||
{ title: '报销单据' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '回老家',
|
||||
scene: '回老家',
|
||||
items: [
|
||||
{ title: '身份证 / 证件' },
|
||||
{ title: '钥匙' },
|
||||
{ title: '手机充电器 + 充电宝' },
|
||||
{ title: '换洗衣物' },
|
||||
{ title: '洗漱包' },
|
||||
{ title: '常用药' },
|
||||
{ title: '给家人的礼物 / 伴手礼' },
|
||||
{ title: '长辈的药品 / 营养品' },
|
||||
{ title: '口罩' },
|
||||
{ title: '现金零钱' },
|
||||
{ title: '车票 / 加油 / 行程确认' },
|
||||
{ title: '家里门窗水电检查' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
async function ensureBuiltin() {
|
||||
const count = await prisma.checkListTemplate.count({ where: { builtin: true } })
|
||||
if (count >= BUILTIN_TEMPLATES.length) return
|
||||
for (const t of BUILTIN_TEMPLATES) {
|
||||
const exist = await prisma.checkListTemplate.findFirst({ where: { builtin: true, name: t.name } })
|
||||
if (exist) continue
|
||||
await prisma.checkListTemplate.create({
|
||||
data: { name: t.name, scene: t.scene, items: t.items, builtin: true, userId: null },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const LIST_INCLUDE = { items: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] } }
|
||||
|
||||
function withProgress(list) {
|
||||
const items = list.items || []
|
||||
const done = items.filter((i) => i.done).length
|
||||
return {
|
||||
...list,
|
||||
items,
|
||||
total: items.length,
|
||||
done,
|
||||
percent: items.length > 0 ? done / items.length : 0,
|
||||
completed: items.length > 0 && done === items.length,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 模板 ----------
|
||||
|
||||
// GET /api/checklists/templates
|
||||
router.get('/templates', async (_req, res, next) => {
|
||||
try {
|
||||
await ensureBuiltin()
|
||||
const rows = await prisma.checkListTemplate.findMany({
|
||||
where: { OR: [{ builtin: true }, { userId: _req.user.sub }] },
|
||||
orderBy: [{ builtin: 'desc' }, { id: 'asc' }],
|
||||
})
|
||||
res.json({ items: rows })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/checklists/templates 另存为模板 { name, scene, items: [{title, note}] }
|
||||
router.post('/templates', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const { name, scene = '', items = [] } = req.body || {}
|
||||
const cleanName = String(name || '').trim()
|
||||
if (!cleanName) return res.status(400).json({ error: '模板名称不能为空' })
|
||||
const list = (Array.isArray(items) ? items : [])
|
||||
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
||||
.filter((i) => i.title)
|
||||
if (!list.length) return res.status(400).json({ error: '模板至少需要一个检查项' })
|
||||
const row = await prisma.checkListTemplate.create({
|
||||
data: { userId, name: cleanName, scene: String(scene), items: list, builtin: false },
|
||||
})
|
||||
res.json({ item: row })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/checklists/templates/:id
|
||||
router.delete('/templates/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const exist = await prisma.checkListTemplate.findFirst({ where: { id, userId } })
|
||||
if (!exist) return res.status(404).json({ error: '模板不存在或无权删除' })
|
||||
await prisma.checkListTemplate.delete({ where: { id } })
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 清单 ----------
|
||||
|
||||
// GET /api/checklists?archived=0|1
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const archived = String(req.query.archived || '0') === '1'
|
||||
const rows = await prisma.checkList.findMany({
|
||||
where: { userId, archived },
|
||||
include: LIST_INCLUDE,
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
})
|
||||
res.json({ items: rows.map(withProgress) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/checklists { title, scene?, note?, items?: string[] | [{title,note}], templateId? }
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const { title, scene = '', note = '', items = [], templateId } = req.body || {}
|
||||
const cleanTitle = String(title || '').trim()
|
||||
if (!cleanTitle) return res.status(400).json({ error: '清单名称不能为空' })
|
||||
|
||||
let seed = (Array.isArray(items) ? items : [])
|
||||
.map((i) => (typeof i === 'string' ? { title: i } : i))
|
||||
.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') }))
|
||||
.filter((i) => i.title)
|
||||
|
||||
if (!seed.length && templateId) {
|
||||
const tpl = await prisma.checkListTemplate.findFirst({
|
||||
where: { id: Number(templateId), OR: [{ builtin: true }, { userId }] },
|
||||
})
|
||||
if (tpl && Array.isArray(tpl.items)) {
|
||||
seed = tpl.items.map((i) => ({ title: String(i?.title || '').trim(), note: String(i?.note || '') })).filter((i) => i.title)
|
||||
}
|
||||
}
|
||||
|
||||
const row = await prisma.checkList.create({
|
||||
data: {
|
||||
userId,
|
||||
title: cleanTitle,
|
||||
scene: String(scene),
|
||||
note: String(note),
|
||||
items: { create: seed.map((i, idx) => ({ title: i.title, note: i.note, sortOrder: idx })) },
|
||||
},
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
res.json({ item: withProgress(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// GET /api/checklists/:id
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const row = await prisma.checkList.findFirst({ where: { id, userId }, include: LIST_INCLUDE })
|
||||
if (!row) return res.status(404).json({ error: '清单不存在' })
|
||||
res.json({ item: withProgress(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/checklists/:id { title?, scene?, note?, archived? }
|
||||
router.put('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const exist = await prisma.checkList.findFirst({ where: { id, userId } })
|
||||
if (!exist) return res.status(404).json({ error: '清单不存在' })
|
||||
const { title, scene, note, archived } = req.body || {}
|
||||
const data = {}
|
||||
if (title !== undefined) {
|
||||
const t = String(title).trim()
|
||||
if (!t) return res.status(400).json({ error: '清单名称不能为空' })
|
||||
data.title = t
|
||||
}
|
||||
if (scene !== undefined) data.scene = String(scene)
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (archived !== undefined) data.archived = !!archived
|
||||
const row = await prisma.checkList.update({ where: { id }, data, include: LIST_INCLUDE })
|
||||
res.json({ item: withProgress(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/checklists/:id
|
||||
router.delete('/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const exist = await prisma.checkList.findFirst({ where: { id, userId } })
|
||||
if (!exist) return res.status(404).json({ error: '清单不存在' })
|
||||
await prisma.checkListItem.deleteMany({ where: { listId: id } })
|
||||
await prisma.checkList.delete({ where: { id } })
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/checklists/:id/duplicate 复制一份新清单 (勾选状态重置)
|
||||
router.post('/:id/duplicate', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const src = await prisma.checkList.findFirst({ where: { id, userId }, include: LIST_INCLUDE })
|
||||
if (!src) return res.status(404).json({ error: '清单不存在' })
|
||||
const row = await prisma.checkList.create({
|
||||
data: {
|
||||
userId,
|
||||
title: src.title + ' 副本',
|
||||
scene: src.scene,
|
||||
note: src.note,
|
||||
items: {
|
||||
create: src.items.map((i, idx) => ({ title: i.title, note: i.note, done: false, sortOrder: idx })),
|
||||
},
|
||||
},
|
||||
include: LIST_INCLUDE,
|
||||
})
|
||||
res.json({ item: withProgress(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// ---------- 检查项 ----------
|
||||
|
||||
// POST /api/checklists/:id/items { title, note? }
|
||||
router.post('/:id/items', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const listId = Number(req.params.id)
|
||||
const list = await prisma.checkList.findFirst({ where: { id: listId, userId } })
|
||||
if (!list) return res.status(404).json({ error: '清单不存在' })
|
||||
const title = String((req.body || {}).title || '').trim()
|
||||
if (!title) return res.status(400).json({ error: '检查项不能为空' })
|
||||
if (title.length > 40) return res.status(400).json({ error: '检查项不能超过 40 个字' })
|
||||
const max = await prisma.checkListItem.findFirst({ where: { listId }, orderBy: { sortOrder: 'desc' } })
|
||||
const row = await prisma.checkListItem.create({
|
||||
data: { listId, title, note: String((req.body || {}).note || ''), sortOrder: (max?.sortOrder || 0) + 1 },
|
||||
})
|
||||
res.json({ item: row })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// PUT /api/checklists/items/:id { title?, note?, done?, sortOrder? }
|
||||
router.put('/items/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const exist = await prisma.checkListItem.findFirst({ where: { id, list: { userId } } })
|
||||
if (!exist) return res.status(404).json({ error: '检查项不存在' })
|
||||
const { title, note, done, sortOrder } = req.body || {}
|
||||
const data = {}
|
||||
if (title !== undefined) {
|
||||
const t = String(title).trim()
|
||||
if (!t) return res.status(400).json({ error: '检查项不能为空' })
|
||||
data.title = t
|
||||
}
|
||||
if (note !== undefined) data.note = String(note)
|
||||
if (sortOrder !== undefined) data.sortOrder = Number(sortOrder) || 0
|
||||
if (done !== undefined) {
|
||||
data.done = !!done
|
||||
data.doneAt = done ? new Date() : null
|
||||
}
|
||||
const row = await prisma.checkListItem.update({ where: { id }, data })
|
||||
res.json({ item: row })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// DELETE /api/checklists/items/:id
|
||||
router.delete('/items/:id', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const id = Number(req.params.id)
|
||||
const exist = await prisma.checkListItem.findFirst({ where: { id, list: { userId } } })
|
||||
if (!exist) return res.status(404).json({ error: '检查项不存在' })
|
||||
await prisma.checkListItem.delete({ where: { id } })
|
||||
res.json({ ok: true })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
// POST /api/checklists/:id/check-all { done: boolean } 一键全选 / 全不选
|
||||
router.post('/:id/check-all', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.sub
|
||||
const listId = Number(req.params.id)
|
||||
const list = await prisma.checkList.findFirst({ where: { id: listId, userId } })
|
||||
if (!list) return res.status(404).json({ error: '清单不存在' })
|
||||
const done = !!(req.body || {}).done
|
||||
await prisma.checkListItem.updateMany({
|
||||
where: { listId },
|
||||
data: { done, doneAt: done ? new Date() : null },
|
||||
})
|
||||
const row = await prisma.checkList.findFirst({ where: { id: listId }, include: LIST_INCLUDE })
|
||||
res.json({ item: withProgress(row) })
|
||||
} catch (e) { next(e) }
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user