Docker Build and Push / build-image (push) Successful in 1m22s
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭, 焦点离开且无内容时自动收起 - 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单 - Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片, 客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位 显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
// 任务模块: 项目 / 成员 / 看板列
|
|
import { Router } from 'express'
|
|
import { prisma } from '../db.js'
|
|
import { authRequired } from '../middleware.js'
|
|
import { loadProjectForUser, loadColumns, loadMemberIds } from '../task-domain/access.js'
|
|
|
|
const router = Router()
|
|
router.use(authRequired)
|
|
|
|
// 新建项目时的默认 4 列 (T-COL-01, T-BD-09)
|
|
export const DEFAULT_COLUMNS = [
|
|
{ name: 'Plan', sortOrder: 0, boundStatus: 'todo' },
|
|
{ name: 'Doing', sortOrder: 1, boundStatus: 'doing' },
|
|
{ name: 'Action', sortOrder: 2, boundStatus: null },
|
|
{ name: 'Done', sortOrder: 3, boundStatus: 'done' },
|
|
]
|
|
|
|
export const MAX_COLUMNS = 10
|
|
const MAX_NAME = 20
|
|
|
|
/** 建 4 个默认列 */
|
|
async function createDefaultColumns(projectId) {
|
|
await prisma.boardColumn.createMany({
|
|
data: DEFAULT_COLUMNS.map((c) => ({ ...c, projectId, retention: 'forever' })),
|
|
})
|
|
}
|
|
|
|
/** 确保用户有一个个人默认项目 (T-PJ-01), 幂等 */
|
|
export async function ensurePersonalProject(userId) {
|
|
const exist = await prisma.project.findFirst({ where: { ownerId: userId, isPersonal: true } })
|
|
if (exist) return exist
|
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { name: true } })
|
|
const name = `${user?.name || '我'}的任务`
|
|
const project = await prisma.project.create({
|
|
data: { name, ownerId: userId, isPersonal: true, members: { create: { userId } } },
|
|
})
|
|
await createDefaultColumns(project.id)
|
|
return project
|
|
}
|
|
|
|
// 任务类型标签: 项目级自定义, 未配置时用默认四类
|
|
export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
|
|
|
|
export function normTaskKinds(v) {
|
|
if (!Array.isArray(v)) return null
|
|
const out = []
|
|
for (const k of v) {
|
|
const s = String(k || '').trim().slice(0, 20)
|
|
if (s && !out.includes(s)) out.push(s)
|
|
if (out.length >= 30) break
|
|
}
|
|
return out
|
|
}
|
|
|
|
function projectSummary(p) {
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
ownerId: p.ownerId,
|
|
isPersonal: p.isPersonal,
|
|
archived: p.archived,
|
|
createdAt: p.createdAt,
|
|
updatedAt: p.updatedAt,
|
|
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
|
icon: p.icon || null,
|
|
memberCount: p._count?.members ?? undefined,
|
|
taskCount: p._count?.tasks ?? undefined,
|
|
}
|
|
}
|
|
|
|
// ---------- 项目 ----------
|
|
|
|
// GET /api/projects 我参与的项目
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
await ensurePersonalProject(userId)
|
|
const rows = await prisma.project.findMany({
|
|
where: { members: { some: { userId } } },
|
|
include: { _count: { select: { members: true, tasks: true } } },
|
|
orderBy: [{ archived: 'asc' }, { updatedAt: 'desc' }],
|
|
})
|
|
res.json({ items: rows.map(projectSummary) })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// POST /api/projects { name }
|
|
router.post('/', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const name = String(req.body?.name || '').trim()
|
|
if (!name) return res.status(400).json({ error: '项目名称不能为空' })
|
|
if (name.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
|
|
|
const project = await prisma.project.create({
|
|
data: { name, ownerId: userId, isPersonal: false, members: { create: { userId } } },
|
|
})
|
|
await createDefaultColumns(project.id)
|
|
const full = await prisma.project.findUnique({
|
|
where: { id: project.id },
|
|
include: { _count: { select: { members: true, tasks: true } } },
|
|
})
|
|
res.json({ item: projectSummary(full) })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// GET /api/projects/:id 详情(项目 + 成员 + 列)
|
|
router.get('/:id', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
const [members, columns] = await Promise.all([
|
|
prisma.projectMember.findMany({
|
|
where: { projectId: project.id },
|
|
include: { user: { select: { id: true, name: true, email: true } } },
|
|
orderBy: { createdAt: 'asc' },
|
|
}),
|
|
loadColumns(project.id),
|
|
])
|
|
res.json({
|
|
item: {
|
|
...projectSummary(project),
|
|
members: members.map((m) => ({ userId: m.userId, name: m.user.name, email: m.user.email, permission: m.permission })),
|
|
columns,
|
|
notes: Array.isArray(project.notes) ? project.notes : [],
|
|
},
|
|
})
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// PUT /api/projects/:id { name?, archived?, notes? }
|
|
router.put('/:id', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
const { name, archived, notes, taskKinds, icon } = req.body || {}
|
|
const data = {}
|
|
if (name !== undefined) {
|
|
const t = String(name).trim()
|
|
if (!t) return res.status(400).json({ error: '项目名称不能为空' })
|
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
|
data.name = t
|
|
}
|
|
// 任务类型标签: 整体替换; 空数组回落默认
|
|
if (taskKinds !== undefined) {
|
|
if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' })
|
|
const kinds = normTaskKinds(taskKinds)
|
|
if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' })
|
|
data.taskKinds = kinds
|
|
}
|
|
// 项目图标: data URL (客户端已裁剪), null 清除回退首字色块
|
|
if (icon !== undefined && icon !== null) {
|
|
const s = String(icon)
|
|
if (!s.startsWith('data:image/')) return res.status(400).json({ error: '图标格式非法' })
|
|
if (s.length > 300_000) return res.status(400).json({ error: '图标过大' })
|
|
data.icon = s
|
|
} else if (icon === null) {
|
|
data.icon = null
|
|
}
|
|
if (archived !== undefined) data.archived = !!archived
|
|
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
|
if (notes !== undefined) {
|
|
if (!Array.isArray(notes)) return res.status(400).json({ error: '信息块格式非法' })
|
|
data.notes = notes.slice(0, 50).map((n) => ({
|
|
title: String(n?.title ?? '').slice(0, 50),
|
|
content: String(n?.content ?? '').slice(0, 20000),
|
|
}))
|
|
}
|
|
const row = await prisma.project.update({ where: { id: project.id }, data })
|
|
res.json({ item: projectSummary(row) })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// DELETE /api/projects/:id 个人项目拒绝 (T-PJ-02, T-EX-09)
|
|
router.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可删除' })
|
|
await prisma.project.delete({ where: { id: project.id } })
|
|
res.json({ ok: true })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// ---------- 成员 ----------
|
|
|
|
// POST /api/projects/:id/members { userId } | { email }
|
|
router.post('/:id/members', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可添加成员' })
|
|
|
|
const { userId: targetId, email } = req.body || {}
|
|
let target = null
|
|
if (targetId) target = await prisma.user.findUnique({ where: { id: Number(targetId) } })
|
|
else if (email) target = await prisma.user.findFirst({ where: { email: String(email).trim() } })
|
|
if (!target) return res.status(404).json({ error: '用户不存在' })
|
|
|
|
const exist = await prisma.projectMember.findUnique({
|
|
where: { projectId_userId: { projectId: project.id, userId: target.id } },
|
|
})
|
|
if (exist) return res.status(409).json({ error: '该用户已是项目成员' })
|
|
|
|
await prisma.projectMember.create({ data: { projectId: project.id, userId: target.id } })
|
|
res.json({ ok: true, member: { userId: target.id, name: target.name } })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// DELETE /api/projects/:id/members/:uid 移除成员 / 自己退出 (T-PJ-06/07)
|
|
router.delete('/:id/members/:uid', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
if (project.isPersonal) return res.status(400).json({ error: '个人默认项目不可移除成员' })
|
|
|
|
const target = Number(req.params.uid)
|
|
// 只能移除自己, 或是创建人移除他人 (本期权限均等, 允许成员自退)
|
|
if (target !== userId && project.ownerId !== userId) {
|
|
return res.status(403).json({ error: '只能移除自己,或由创建人移除他人' })
|
|
}
|
|
await prisma.projectMember.deleteMany({ where: { projectId: project.id, userId: target } })
|
|
// 成员清空后项目静默保留 (T-PJ-08)
|
|
res.json({ ok: true })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// ---------- 看板列 ----------
|
|
|
|
// GET /api/projects/:id/columns
|
|
router.get('/:id/columns', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
res.json({ items: await loadColumns(project.id) })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// POST /api/projects/:id/columns { name }
|
|
router.post('/:id/columns', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
|
|
const name = String(req.body?.name || '').trim()
|
|
if (!name) return res.status(400).json({ error: '列名不能为空' })
|
|
if (name.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
|
|
|
|
const cols = await loadColumns(project.id)
|
|
if (cols.length >= MAX_COLUMNS) return res.status(400).json({ error: `最多 ${MAX_COLUMNS} 列` })
|
|
if (cols.some((c) => c.name === name)) return res.status(409).json({ error: '列名已存在' })
|
|
|
|
const row = await prisma.boardColumn.create({
|
|
data: {
|
|
projectId: project.id,
|
|
name,
|
|
sortOrder: cols.length ? Math.max(...cols.map((c) => c.sortOrder)) + 1 : 0,
|
|
boundStatus: null,
|
|
retention: 'forever',
|
|
},
|
|
})
|
|
res.json({ item: row })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// PUT /api/projects/:id/columns/order { ids: number[] }
|
|
router.put('/:id/columns/order', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const project = await loadProjectForUser(req.params.id, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number) : []
|
|
const cols = await loadColumns(project.id)
|
|
if (ids.length !== cols.length) return res.status(400).json({ error: '列数量不匹配' })
|
|
|
|
await prisma.$transaction(
|
|
ids.map((id, idx) => prisma.boardColumn.update({ where: { id }, data: { sortOrder: idx } })),
|
|
)
|
|
res.json({ items: await loadColumns(project.id) })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// PUT /api/columns/:id { name?, boundStatus?, retention? }
|
|
export const columnRouter = Router()
|
|
columnRouter.use(authRequired)
|
|
|
|
columnRouter.put('/:id', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const id = Number(req.params.id)
|
|
const col = await prisma.boardColumn.findUnique({ where: { id } })
|
|
if (!col) return res.status(404).json({ error: '列不存在' })
|
|
const project = await loadProjectForUser(col.projectId, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
|
|
const { name, boundStatus, retention } = req.body || {}
|
|
const data = {}
|
|
if (name !== undefined) {
|
|
const t = String(name).trim()
|
|
if (!t) return res.status(400).json({ error: '列名不能为空' })
|
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `列名不能超过 ${MAX_NAME} 字` })
|
|
const dup = await prisma.boardColumn.findFirst({ where: { projectId: col.projectId, name: t, NOT: { id } } })
|
|
if (dup) return res.status(409).json({ error: '列名已存在' })
|
|
data.name = t
|
|
}
|
|
if (boundStatus !== undefined) {
|
|
// null | todo | doing | done
|
|
if (boundStatus !== null && !['todo', 'doing', 'done'].includes(boundStatus)) {
|
|
return res.status(400).json({ error: '绑定状态取值非法' })
|
|
}
|
|
data.boundStatus = boundStatus
|
|
}
|
|
if (retention !== undefined) {
|
|
if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(retention)) {
|
|
return res.status(400).json({ error: '保留选项取值非法' })
|
|
}
|
|
data.retention = retention
|
|
}
|
|
const row = await prisma.boardColumn.update({ where: { id }, data })
|
|
res.json({ item: row })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
// DELETE /api/columns/:id?moveToColumnId=N 删列并迁移任务 (T-COL-04, T-EX-16/17)
|
|
columnRouter.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
const userId = req.user.sub
|
|
const id = Number(req.params.id)
|
|
const col = await prisma.boardColumn.findUnique({ where: { id } })
|
|
if (!col) return res.status(404).json({ error: '列不存在' })
|
|
const project = await loadProjectForUser(col.projectId, userId)
|
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
|
|
|
const cols = await loadColumns(col.projectId)
|
|
if (cols.length <= 1) return res.status(400).json({ error: '至少保留一列' })
|
|
|
|
// 迁移目标: 指定列 否则相邻列(优先后一个, 没有则前一个)
|
|
let targetId = Number(req.query.moveToColumnId) || 0
|
|
if (!targetId) {
|
|
const idx = cols.findIndex((c) => c.id === id)
|
|
targetId = (cols[idx + 1] || cols[idx - 1]).id
|
|
}
|
|
const target = cols.find((c) => c.id === targetId)
|
|
if (!target || target.id === id) return res.status(400).json({ error: '迁移目标列非法' })
|
|
|
|
// 迁移任务: 状态按目标列绑定重新裁决 (设计文档 §2.4)
|
|
const tasks = await prisma.task.findMany({ where: { columnId: id } })
|
|
const newStatus = target.boundStatus || null
|
|
await prisma.task.updateMany({
|
|
where: { columnId: id },
|
|
data: {
|
|
columnId: target.id,
|
|
...(newStatus ? { status: newStatus } : {}),
|
|
},
|
|
})
|
|
await prisma.boardColumn.delete({ where: { id } })
|
|
res.json({ ok: true, moved: tasks.length, toColumnId: target.id })
|
|
} catch (e) { next(e) }
|
|
})
|
|
|
|
export { createDefaultColumns, loadMemberIds }
|
|
export default router
|