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: '家里门窗水电检查' }, ], }, ] export 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, } } // ---------- 模板 ---------- // 模板按名称字母顺序 (中文按拼音) 排序, 前后端共用同一比较器语义 const NAME_COLLATOR = new Intl.Collator('zh-Hans-CN', { numeric: true, sensitivity: 'base' }) const byName = (a, b) => NAME_COLLATOR.compare(String(a.name || ''), String(b.name || '')) // 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 }] }, }) res.json({ items: rows.sort(byName) }) } catch (e) { next(e) } }) // GET /api/checklists/templates/:id 模板详情 router.get('/templates/:id', async (req, res, next) => { try { const row = await prisma.checkListTemplate.findFirst({ where: { id: Number(req.params.id), OR: [{ builtin: true }, { userId: req.user.sub }] }, }) if (!row) return res.status(404).json({ error: '模板不存在' }) res.json({ item: row }) } 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: '模板名称不能为空' }) if (cleanName.length > 20) return res.status(400).json({ error: '模板名称不能超过 20 个字' }) 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) } }) // PUT /api/checklists/templates/:id 改名称 / 检查项 (系统预置模板不可改) router.put('/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, builtin: false } }) if (!exist) return res.status(404).json({ error: '模板不存在、无权限修改或是系统预置模板' }) const { name, scene, items } = 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 (scene !== undefined) data.scene = String(scene) if (items !== undefined) { 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: '模板至少需要一个检查项' }) data.items = list } const row = await prisma.checkListTemplate.update({ where: { id }, data }) 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, builtin: false } }) 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