Docker Build and Push / build-image (push) Successful in 4m34s
- 新增 /api/mcp (Streamable HTTP) 与 41 个工具: 项目/看板列/任务/清单/打卡; - 新增 McpToken 模型与迁移, DB 仅存 SHA-256 哈希, token 绑定用户; - 新增 /api/mcp-tokens 增删改查, 支持独立命名 (如 R1); - 「我的」页新增 AI 接入 (MCP) 区块: 地址复制/新建/一次性展示/删除; - 补充 docs/MCP.md 与样式
357 lines
13 KiB
JavaScript
357 lines
13 KiB
JavaScript
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
|