Merge branch 'dev-0.2.1' into dev-0.2

# Conflicts:
#	generated/prisma/internal/class.ts
#	generated/prisma/internal/prismaNamespace.ts
#	generated/prisma/internal/prismaNamespaceBrowser.ts
#	generated/prisma/models/BoardColumn.ts
#	generated/prisma/models/Project.ts
#	generated/prisma/models/ProjectMember.ts
#	generated/prisma/models/Task.ts
#	generated/prisma/models/TaskMember.ts
#	generated/prisma/models/TaskTag.ts
#	generated/prisma/models/User.ts
#	server/routes/projects.js
#	server/routes/tasks.js
#	src/api/task.js
This commit is contained in:
jif2.zhang
2026-10-26 20:46:25 +08:00
95 changed files with 8694 additions and 1609 deletions
+775 -44
View File
@@ -1,44 +1,775 @@
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 projectApi = {
list: () => api('/projects'),
detail: (id) => api(`/projects/${id}`),
create: (body) => api('/projects', { method: 'POST', body }),
update: (id, body) => api(`/projects/${id}`, { method: 'PUT', body }),
remove: (id) => api(`/projects/${id}`, { method: 'DELETE' }),
addMember: (id, body) => api(`/projects/${id}/members`, { method: 'POST', body }),
removeMember: (id, uid) => api(`/projects/${id}/members/${uid}`, { method: 'DELETE' }),
}
// 看板列
export const columnApi = {
list: (projectId) => api(`/projects/${projectId}/columns`),
add: (projectId, body) => api(`/projects/${projectId}/columns`, { method: 'POST', body }),
update: (id, body) => api(`/columns/${id}`, { method: 'PUT', body }),
remove: (id, moveToColumnId) => api(`/columns/${id}${q({ moveToColumnId })}`, { method: 'DELETE' }),
order: (projectId, ids) => api(`/projects/${projectId}/columns/order`, { method: 'PUT', body: { ids } }),
}
// 任务
export const taskApi = {
list: (projectId) => api(`/projects/${projectId}/tasks`),
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
detail: (id) => api(`/tasks/${id}`),
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
reorder: (projectId, ids) => api(`/projects/${projectId}/tasks/reorder`, { method: 'POST', body: { ids } }),
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
archive: (id) => api(`/tasks/${id}/archive`, { method: 'POST', body: {} }),
unarchive: (id) => api(`/tasks/${id}/unarchive`, { method: 'POST', body: {} }),
archivedList: (projectId, params) => api(`/projects/${projectId}/tasks/archived` + q(params)),
}
// 任务模块数据层 (0.2.1): 已切换为 PowerSync 本地 SQLite
// - 读: 全部来自本地库 (离线可用); 写: 本地写入 -> upload queue -> /api/powersync/upload (按 uuid 幂等)
// - 对页面保持与原 REST 版一致的调用签名与响应形状 (projectApi / columnApi / taskApi)
// - 编号: Project.code / Task.number 由服务端统一分配, 本地新建行同步前分别为空 -> 展示占位
// 见 docs/V0.2/离线编辑模块/0.2.1实施方案.md
import { getPowersyncDb } from '../lib/powersync/db.js'
import { api, getCurrentUserId } from './client.js'
import { filterVisible } from '../pages/task/date.js'
const db = () => getPowersyncDb()
// crypto.randomUUID 仅在安全上下文 (https / localhost) 可用; 部署站为 http 明文, 用 getRandomValues 兜底生成 v4
const uuid = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
const b = crypto.getRandomValues(new Uint8Array(16))
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('')
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`
}
const now = () => new Date().toISOString()
const parseJson = (v, fallback) => {
if (v === null || v === undefined || v === '') return fallback
if (typeof v !== 'string') return v
try { return JSON.parse(v) } catch { return fallback }
}
// 新建项目时的默认 4 列 (与服务端 DEFAULT_COLUMNS 一致)
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 DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化']
const MAX_NAME = 20
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
}
// ---------- 查询助手 ----------
async function getColumns(projectId) {
return db().getAll(
'SELECT * FROM BoardColumn WHERE projectId = ? ORDER BY sortOrder, id',
[String(projectId)],
)
}
async function getProjectMembers(projectId) {
const rows = await db().getAll(
`SELECT pm.userId, pm.permission, u.name, u.email
FROM ProjectMember pm LEFT JOIN User u ON u.id = pm.userId
WHERE pm.projectId = ? ORDER BY pm.createdAt, pm.id`,
[String(projectId)],
)
return rows.map((r) => ({ userId: String(r.userId), name: r.name || String(r.userId), email: r.email || '', permission: r.permission || 'member' }))
}
function memberMapOf(members) {
return new Map(members.map((m) => [m.userId, { id: m.userId, name: m.name }]))
}
async function shapeTasks(rows, columns) {
const columnById = new Map(columns.map((c) => [String(c.id), c]))
const projectIds = [...new Set(rows.map((r) => String(r.projectId)))]
let membersByProject = new Map()
if (projectIds.length) {
const ph = projectIds.map(() => '?').join(',')
const pmRows = await db().getAll(
`SELECT pm.projectId, pm.userId, u.name FROM ProjectMember pm LEFT JOIN User u ON u.id = pm.userId WHERE pm.projectId IN (${ph})`,
projectIds,
)
membersByProject = new Map(projectIds.map((pid) => [pid, new Map()]))
for (const r of pmRows) {
membersByProject.get(String(r.projectId)).set(String(r.userId), { id: String(r.userId), name: r.name || String(r.userId) })
}
}
const taskIds = rows.map((r) => String(r.id))
const memberRows = taskIds.length
? await db().getAll(
`SELECT tm.taskId, tm.userId, u.name FROM TaskMember tm LEFT JOIN User u ON u.id = tm.userId WHERE tm.taskId IN (${taskIds.map(() => '?').join(',')})`,
taskIds,
)
: []
const tagRows = taskIds.length
? await db().getAll(`SELECT taskId, name FROM TaskTag WHERE taskId IN (${taskIds.map(() => '?').join(',')})`, taskIds)
: []
const membersByTask = new Map()
for (const r of memberRows) {
const k = String(r.taskId)
if (!membersByTask.has(k)) membersByTask.set(k, [])
membersByTask.get(k).push({ userId: String(r.userId), name: r.name || String(r.userId) })
}
const tagsByTask = new Map()
for (const r of tagRows) {
const k = String(r.taskId)
if (!tagsByTask.has(k)) tagsByTask.set(k, [])
tagsByTask.get(k).push(r.name)
}
const shapeOne = (t) => {
const members = membersByTask.get(String(t.id)) || []
const usersByProject = membersByProject.get(String(t.projectId)) || new Map()
return {
...t,
extras: parseJson(t.extras, []),
column: columnById.get(String(t.columnId)) || null,
assignee: t.assigneeId ? (usersByProject.get(String(t.assigneeId)) || { id: String(t.assigneeId), name: String(t.assigneeId) }) : null,
members,
tags: tagsByTask.get(String(t.id)) || [],
}
}
return shapeOne
}
function projectSummary(p, memberCount, taskCount) {
return {
id: String(p.id),
uuid: p.uuid,
code: p.code || null,
name: p.name,
ownerId: String(p.ownerId),
isPersonal: !!p.isPersonal,
archived: !!p.archived,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
taskKinds: normTaskKinds(parseJson(p.taskKinds, null)) || DEFAULT_TASK_KINDS,
icon: p.icon || null,
memberCount,
taskCount,
}
}
function assertUser() {
const uid = getCurrentUserId()
if (!uid) throw new Error('未登录')
return String(uid)
}
// ---------- 项目 ----------
export const projectApi = {
// 我参与的项目 (本地库; 个人默认项目缺失且在线时回源一次让服务端补建)
list: async () => {
const uid = assertUser()
let rows = await db().getAll(
`SELECT p.* FROM Project p WHERE p.id IN (SELECT pm.projectId FROM ProjectMember pm WHERE pm.userId = ?)
ORDER BY p.archived, p.updatedAt DESC`,
[uid],
)
if (!rows.some((r) => !!r.isPersonal && String(r.ownerId) === uid)) {
try {
await api('/projects')
await new Promise((r) => setTimeout(r, 1200))
rows = await db().getAll(
`SELECT p.* FROM Project p WHERE p.id IN (SELECT pm.projectId FROM ProjectMember pm WHERE pm.userId = ?)
ORDER BY p.archived, p.updatedAt DESC`,
[uid],
)
} catch { /* 离线: 无个人项目则跳过 */ }
}
const items = []
for (const p of rows) {
const memberCount = await db().getOptional('SELECT COUNT(*) AS n FROM ProjectMember WHERE projectId = ?', [String(p.id)])
const taskCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE projectId = ?', [String(p.id)])
items.push(projectSummary(p, memberCount?.n || 0, taskCount?.n || 0))
}
return { items }
},
detail: async (id) => {
const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)])
if (!p) throw new Error('项目不存在或无权限')
const members = await getProjectMembers(p.id)
const columns = await getColumns(p.id)
const taskCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE projectId = ?', [String(p.id)])
return {
item: {
...projectSummary(p, members.length, taskCount?.n || 0),
members,
columns,
notes: parseJson(p.notes, []),
},
}
},
create: async (body) => {
const uid = assertUser()
const name = String(body?.name || '').trim()
if (!name) throw new Error('项目名称不能为空')
if (name.length > MAX_NAME) throw new Error(`项目名称不能超过 ${MAX_NAME} 字`)
let code = null
const rawCode = body?.code
if (rawCode !== undefined && rawCode !== null && String(rawCode).trim() !== '') {
code = String(rawCode).trim().toUpperCase()
if (!/^[A-Z0-9]{2,8}$/.test(code)) throw new Error('项目编号须为 2~8 位字母数字')
const dup = await db().getOptional('SELECT id FROM Project WHERE code = ?', [code])
if (dup) throw new Error(`项目编号 ${code} 已被占用`)
}
const id = uuid()
const ts = now()
await db().writeTransaction(async (tx) => {
await tx.execute(
'INSERT INTO Project (id, uuid, code, name, ownerId, isPersonal, archived, notes, taskKinds, icon, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, id, code, name, uid, 0, 0, '[]', null, null, ts, ts],
)
await tx.execute(
'INSERT INTO ProjectMember (id, uuid, projectId, userId, permission, createdAt) VALUES (?, ?, ?, ?, ?, ?)',
[uuid(), uuid(), id, uid, 'member', ts],
)
for (const c of DEFAULT_COLUMNS) {
await tx.execute(
'INSERT INTO BoardColumn (id, uuid, projectId, name, sortOrder, boundStatus, retention, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[uuid(), uuid(), id, c.name, c.sortOrder, c.boundStatus, 'forever', ts, ts],
)
}
})
const p = await db().get('SELECT * FROM Project WHERE id = ?', [id])
return { item: projectSummary(p, 1, 0) }
},
update: async (id, body) => {
await db().getOptional('SELECT id FROM Project WHERE id = ?', [String(id)]).then((p) => {
if (!p) throw new Error('项目不存在或无权限')
})
const data = {}
if (body?.name !== undefined) {
const t = String(body.name).trim()
if (!t) throw new Error('项目名称不能为空')
if (t.length > MAX_NAME) throw new Error(`项目名称不能超过 ${MAX_NAME} 字`)
data.name = t
}
if (body?.code !== undefined) {
const c = String(body.code || '').trim().toUpperCase()
if (!/^[A-Z0-9]{2,8}$/.test(c)) throw new Error('项目编号须为 2~8 位字母数字')
const dup = await db().getOptional('SELECT id FROM Project WHERE code = ? AND id != ?', [c, String(id)])
if (dup) throw new Error(`项目编号 ${c} 已被占用`)
data.code = c
}
if (body?.taskKinds !== undefined) {
const kinds = normTaskKinds(body.taskKinds)
if (!kinds?.length) throw new Error('至少保留一个任务类型')
data.taskKinds = JSON.stringify(kinds)
}
if (body?.icon !== undefined) {
if (body.icon === null) data.icon = null
else {
const s = String(body.icon)
if (!s.startsWith('data:image/')) throw new Error('图标格式非法')
if (s.length > 300_000) throw new Error('图标过大')
data.icon = s
}
}
if (body?.archived !== undefined) data.archived = body.archived ? 1 : 0
if (body?.notes !== undefined) {
if (!Array.isArray(body.notes)) throw new Error('信息块格式非法')
data.notes = JSON.stringify(body.notes.slice(0, 50).map((n) => ({
title: String(n?.title ?? '').slice(0, 50),
content: String(n?.content ?? '').slice(0, 20000),
})))
}
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
if (sets.length) {
await db().execute(
`UPDATE Project SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`,
[...Object.values(data), now(), String(id)],
)
}
const p = await db().get('SELECT * FROM Project WHERE id = ?', [String(id)])
const memberCount = await db().getOptional('SELECT COUNT(*) AS n FROM ProjectMember WHERE projectId = ?', [String(p.id)])
const taskCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE projectId = ?', [String(p.id)])
return { item: projectSummary(p, memberCount?.n || 0, taskCount?.n || 0) }
},
remove: async (id) => {
const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)])
if (!p) throw new Error('项目不存在或无权限')
if (!!p.isPersonal) throw new Error('个人默认项目不可删除')
// 手动级联 (本地 SQLite 无外键级联)
await db().writeTransaction(async (tx) => {
const taskIds = (await tx.getAll('SELECT id FROM Task WHERE projectId = ?', [String(p.id)])).map((r) => String(r.id))
if (taskIds.length) {
const ph = taskIds.map(() => '?').join(',')
await tx.execute(`DELETE FROM TaskMember WHERE taskId IN (${ph})`, taskIds)
await tx.execute(`DELETE FROM TaskTag WHERE taskId IN (${ph})`, taskIds)
}
await tx.execute('DELETE FROM Task WHERE projectId = ?', [String(p.id)])
await tx.execute('DELETE FROM BoardColumn WHERE projectId = ?', [String(p.id)])
await tx.execute('DELETE FROM ProjectMember WHERE projectId = ?', [String(p.id)])
await tx.execute('DELETE FROM Project WHERE id = ?', [String(p.id)])
})
return { ok: true }
},
addMember: async (id, body) => {
const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)])
if (!p) throw new Error('项目不存在或无权限')
if (!!p.isPersonal) throw new Error('个人默认项目不可添加成员')
let target = null
if (body?.userId) target = await db().getOptional('SELECT id, name FROM User WHERE id = ?', [String(body.userId)])
else if (body?.email) target = await db().getOptional('SELECT id, name FROM User WHERE email = ?', [String(body.email).trim()])
if (!target) throw new Error('用户不存在')
const dup = await db().getOptional('SELECT id FROM ProjectMember WHERE projectId = ? AND userId = ?', [String(p.id), String(target.id)])
if (dup) throw new Error('该用户已是项目成员')
await db().execute(
'INSERT INTO ProjectMember (id, uuid, projectId, userId, permission, createdAt) VALUES (?, ?, ?, ?, ?, ?)',
[uuid(), uuid(), String(p.id), String(target.id), 'member', now()],
)
return { ok: true, member: { userId: String(target.id), name: target.name } }
},
removeMember: async (id, uid) => {
const p = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(id)])
if (!p) throw new Error('项目不存在或无权限')
if (!!p.isPersonal) throw new Error('个人默认项目不可移除成员')
const me = getCurrentUserId()
if (String(uid) !== me && String(p.ownerId) !== me) throw new Error('只能移除自己,或由创建人移除他人')
await db().execute('DELETE FROM ProjectMember WHERE projectId = ? AND userId = ?', [String(p.id), String(uid)])
return { ok: true }
},
}
// ---------- 看板列 ----------
export const columnApi = {
list: async (projectId) => ({ items: await getColumns(projectId) }),
add: async (projectId, body) => {
const name = String(body?.name || '').trim()
if (!name) throw new Error('列名不能为空')
if (name.length > MAX_NAME) throw new Error(`列名不能超过 ${MAX_NAME} 字`)
const cols = await getColumns(projectId)
if (cols.length >= 10) throw new Error('最多 10 列')
if (cols.some((c) => c.name === name)) throw new Error('列名已存在')
const id = uuid()
const ts = now()
await db().execute(
'INSERT INTO BoardColumn (id, uuid, projectId, name, sortOrder, boundStatus, retention, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, id, String(projectId), name, cols.length ? Math.max(...cols.map((c) => Number(c.sortOrder))) + 1 : 0, null, 'forever', ts, ts],
)
return { item: await db().get('SELECT * FROM BoardColumn WHERE id = ?', [id]) }
},
update: async (id, body) => {
const col = await db().getOptional('SELECT * FROM BoardColumn WHERE id = ?', [String(id)])
if (!col) throw new Error('列不存在')
const data = {}
if (body?.name !== undefined) {
const t = String(body.name).trim()
if (!t) throw new Error('列名不能为空')
if (t.length > MAX_NAME) throw new Error(`列名不能超过 ${MAX_NAME} 字`)
const dup = await db().getOptional('SELECT id FROM BoardColumn WHERE projectId = ? AND name = ? AND id != ?', [String(col.projectId), t, String(id)])
if (dup) throw new Error('列名已存在')
data.name = t
}
if (body?.boundStatus !== undefined) {
if (body.boundStatus !== null && !['todo', 'doing', 'done'].includes(body.boundStatus)) throw new Error('绑定状态取值非法')
data.boundStatus = body.boundStatus
}
if (body?.retention !== undefined) {
if (!['never', 'today', '3d', 'week', 'month', 'forever'].includes(body.retention)) throw new Error('保留选项取值非法')
data.retention = body.retention
}
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
if (sets.length) {
await db().execute(`UPDATE BoardColumn SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, [...Object.values(data), now(), String(id)])
}
return { item: await db().get('SELECT * FROM BoardColumn WHERE id = ?', [String(id)]) }
},
remove: async (id, moveToColumnId) => {
const col = await db().getOptional('SELECT * FROM BoardColumn WHERE id = ?', [String(id)])
if (!col) throw new Error('列不存在')
const cols = await getColumns(col.projectId)
if (cols.length <= 1) throw new Error('至少保留一列')
let targetId = String(moveToColumnId || '')
if (!targetId) {
const idx = cols.findIndex((c) => String(c.id) === String(id))
targetId = String((cols[idx + 1] || cols[idx - 1]).id)
}
const target = cols.find((c) => String(c.id) === targetId)
if (!target || String(target.id) === String(id)) throw new Error('迁移目标列非法')
await db().writeTransaction(async (tx) => {
// 迁移任务: 状态按目标列绑定重新裁决 (与服务端同口径)
if (target.boundStatus) {
await tx.execute('UPDATE Task SET columnId = ?, status = ?, updatedAt = ? WHERE columnId = ?', [targetId, target.boundStatus, now(), String(id)])
} else {
await tx.execute('UPDATE Task SET columnId = ?, updatedAt = ? WHERE columnId = ?', [targetId, now(), String(id)])
}
await tx.execute('DELETE FROM BoardColumn WHERE id = ?', [String(id)])
})
return { ok: true, toColumnId: targetId }
},
order: async (projectId, ids) => {
const list = Array.isArray(ids) ? ids.map(String) : []
if (!list.length) throw new Error('ids 不能为空')
await db().writeTransaction(async (tx) => {
for (let i = 0; i < list.length; i++) {
await tx.execute('UPDATE BoardColumn SET sortOrder = ?, updatedAt = ? WHERE id = ?', [i, now(), list[i]])
}
})
return { items: await getColumns(projectId) }
},
}
// ---------- 任务 ----------
function assertTitle(t) {
const s = String(t || '').trim()
if (!s) throw new Error('任务标题不能为空')
if (s.length > 30) throw new Error('任务标题不能超过 30 字')
return s
}
function parseMarkDate(v) {
if (v === null || v === undefined || v === '') return null
const d = new Date(v)
if (Number.isNaN(d.getTime())) throw new Error('时间标记格式非法')
return d.toISOString()
}
export const taskApi = {
list: async (projectId) => {
const columns = await getColumns(projectId)
const rows = await db().getAll(
'SELECT * FROM Task WHERE projectId = ? AND parentId IS NULL AND archived = 0 ORDER BY sortOrder, createdAt, id',
[String(projectId)],
)
const shape = await shapeTasks(rows, columns)
const shaped = rows.map(shape)
// 子任务挂载 (与服务端 TASK_INCLUDE.children 同构)
for (const t of shaped) {
const childRows = await db().getAll(
'SELECT * FROM Task WHERE parentId = ? ORDER BY sortOrder, id',
[String(t.id)],
)
const childShape = await shapeTasks(childRows, columns)
t.children = childRows.map(childShape)
}
const visible = filterVisible(shaped, columns)
return { items: visible, columns, total: shaped.length }
},
create: async (projectId, body) => {
const uid = assertUser()
const title = assertTitle(body?.title)
const columns = await getColumns(projectId)
if (!columns.length) throw new Error('项目没有可用的看板列')
let parentId = null
if (body?.parentId) {
const parent = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(body.parentId)])
if (!parent) throw new Error('父任务不存在')
if (String(parent.projectId) !== String(projectId)) throw new Error('子任务必须与父任务同项目')
if (parent.parentId) throw new Error('仅支持一级子任务')
parentId = String(parent.id)
}
const memberIds = await db().getAll('SELECT userId FROM ProjectMember WHERE projectId = ?', [String(projectId)])
const memberSet = new Set(memberIds.map((r) => String(r.userId)))
let finalAssignee = null
if (body?.assigneeId === undefined) finalAssignee = memberSet.has(uid) ? uid : null
else if (body?.assigneeId !== null) {
const aid = String(body.assigneeId)
if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员')
finalAssignee = aid
}
const firstCol = columns[0]
const id = uuid()
const ts = now()
await db().writeTransaction(async (tx) => {
await tx.execute(
`INSERT INTO Task (id, uuid, projectId, columnId, parentId, title, description, kind, markDate, status, doneAt, assigneeId, creatorId, extras, sortOrder, number, archived, archivedAt, archiveSource, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, NULL, ?, ?)`,
[id, id, String(projectId), String(firstCol.id), parentId, title, String(body?.description || ''),
body?.kind ? String(body.kind).trim().slice(0, 20) : null, parseMarkDate(body?.markDate),
firstCol.boundStatus || 'todo', null, finalAssignee, uid, '[]', 0, null, ts, ts],
)
for (const mid of (Array.isArray(body?.memberIds) ? body.memberIds : []).map(String).filter((x) => memberSet.has(x))) {
await tx.execute('INSERT INTO TaskMember (id, uuid, taskId, userId) VALUES (?, ?, ?, ?)', [uuid(), uuid(), id, mid])
}
for (const name of (Array.isArray(body?.tags) ? body.tags : []).filter(Boolean).map((n) => String(n).slice(0, 20))) {
await tx.execute('INSERT INTO TaskTag (id, uuid, taskId, name) VALUES (?, ?, ?, ?)', [uuid(), uuid(), id, name])
}
})
const row = await db().get('SELECT * FROM Task WHERE id = ?', [id])
const shape = await shapeTasks([row], columns)
return { item: shape(row) }
},
detail: async (id) => {
const row = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!row) throw new Error('任务不存在或无权限')
const columns = await getColumns(row.projectId)
const shape = await shapeTasks([row], columns)
return { item: shape(row) }
},
update: async (id, body) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const columns = await getColumns(task.projectId)
const data = {}
if (body?.title !== undefined) data.title = assertTitle(body.title)
if (body?.description !== undefined) data.description = String(body.description)
if (body?.kind !== undefined) data.kind = body.kind ? String(body.kind).trim().slice(0, 20) : null
if (body?.markDate !== undefined) data.markDate = parseMarkDate(body.markDate)
if (body?.extras !== undefined) {
if (!Array.isArray(body.extras)) throw new Error('扩展信息格式非法')
data.extras = JSON.stringify(body.extras.map((e) => ({
label: String(e?.label ?? '').slice(0, 40),
value: String(e?.value ?? ''),
url: e?.url ? String(e.url) : '',
})))
}
if (body?.sortOrder !== undefined) data.sortOrder = Number(body.sortOrder) || 0
const memberRows = await db().getAll('SELECT userId FROM ProjectMember WHERE projectId = ?', [String(task.projectId)])
const memberSet = new Set(memberRows.map((r) => String(r.userId)))
if (body?.assigneeId !== undefined) {
if (body.assigneeId === null || body.assigneeId === '') data.assigneeId = null
else {
const aid = String(body.assigneeId)
if (!memberSet.has(aid)) throw new Error('负责人必须是项目成员')
data.assigneeId = aid
}
}
// 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径)
let status = task.status
let columnId = String(task.columnId)
const patch = {}
if (body?.columnId !== undefined) patch.columnId = String(body.columnId)
if (body?.status !== undefined) {
if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法')
patch.status = body.status
}
if (patch.columnId !== undefined && !columns.some((c) => String(c.id) === patch.columnId)) {
throw new Error('看板列不存在')
}
if (Object.keys(patch).length) {
status = patch.status !== undefined ? patch.status : task.status
columnId = patch.columnId !== undefined ? patch.columnId : String(task.columnId)
if (!task.parentId) {
const ordered = [...columns].sort((a, b) => (Number(a.sortOrder) - Number(b.sortOrder)) || String(a.id).localeCompare(String(b.id)))
const targetColumn = ordered.find((c) => String(c.id) === columnId)
if (patch.columnId !== undefined && targetColumn) {
if (targetColumn.boundStatus) status = targetColumn.boundStatus
}
if (patch.status !== undefined) {
if (!(targetColumn && targetColumn.boundStatus === status)) {
const hit = ordered.find((c) => c.boundStatus === status)
if (hit) columnId = String(hit.id)
}
}
}
data.status = status
data.columnId = columnId
}
if (data.status === 'done' && task.status !== 'done') data.doneAt = now()
else if (data.status !== undefined && data.status !== 'done') data.doneAt = null
// 拖拽降为子任务
if (body?.parentId !== undefined) {
if (body.parentId === null) {
data.parentId = null
} else {
const pid = String(body.parentId)
if (pid === String(task.id)) throw new Error('不能成为自己的子任务')
const parent = await db().getOptional('SELECT * FROM Task WHERE id = ?', [pid])
if (!parent) throw new Error('目标任务不存在')
if (String(parent.projectId) !== String(task.projectId)) throw new Error('子任务必须与父任务同项目')
if (parent.parentId) throw new Error('仅支持一级子任务')
const childCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE parentId = ?', [String(task.id)])
if ((childCount?.n || 0) > 0) throw new Error('该任务已有子任务,不能成为子任务')
data.parentId = pid
}
}
await db().writeTransaction(async (tx) => {
const sets = Object.keys(data).map((k) => `"${k}" = ?`)
if (sets.length) {
await tx.execute(`UPDATE Task SET ${sets.join(', ')}, updatedAt = ? WHERE id = ?`, [...Object.values(data), now(), String(task.id)])
}
if (Array.isArray(body?.memberIds)) {
const ids = body.memberIds.map(String).filter((x) => memberSet.has(x))
await tx.execute('DELETE FROM TaskMember WHERE taskId = ?', [String(task.id)])
for (const mid of ids) {
await tx.execute('INSERT INTO TaskMember (id, uuid, taskId, userId) VALUES (?, ?, ?, ?)', [uuid(), uuid(), String(task.id), mid])
}
}
if (Array.isArray(body?.tags)) {
await tx.execute('DELETE FROM TaskTag WHERE taskId = ?', [String(task.id)])
const names = body.tags.map((n) => String(n).trim()).filter(Boolean).slice(0, 10)
for (const name of names) {
await tx.execute('INSERT INTO TaskTag (id, uuid, taskId, name) VALUES (?, ?, ?, ?)', [uuid(), uuid(), String(task.id), name.slice(0, 20)])
}
}
})
const row = await db().get('SELECT * FROM Task WHERE id = ?', [String(task.id)])
const shape = await shapeTasks([row], columns)
return { item: shape(row) }
},
reorder: async (projectId, ids) => {
const list = Array.isArray(ids) ? ids.map(String).filter(Boolean) : []
if (!list.length) throw new Error('ids 不能为空')
await db().writeTransaction(async (tx) => {
for (let i = 0; i < list.length; i++) {
await tx.execute('UPDATE Task SET sortOrder = ?, updatedAt = ? WHERE id = ?', [i, now(), list[i]])
}
})
return { ok: true }
},
remove: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const childCount = await db().getOptional('SELECT COUNT(*) AS n FROM Task WHERE parentId = ?', [String(task.id)])
await db().writeTransaction(async (tx) => {
const ids = [String(task.id), ...((await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])).map((r) => String(r.id)))]
const ph = ids.map(() => '?').join(',')
await tx.execute(`DELETE FROM TaskMember WHERE taskId IN (${ph})`, ids)
await tx.execute(`DELETE FROM TaskTag WHERE taskId IN (${ph})`, ids)
await tx.execute(`DELETE FROM Task WHERE id IN (${ph})`, ids)
})
return { ok: true, deletedSubtasks: childCount?.n || 0 }
},
copy: async (id, projectId) => {
const uid = assertUser()
const source = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!source) throw new Error('任务不存在或无权限')
const targetProject = await db().getOptional('SELECT * FROM Project WHERE id = ?', [String(projectId)])
if (!targetProject) throw new Error('目标项目不存在或你不是其成员')
const targetColumns = await getColumns(targetProject.id)
if (!targetColumns.length) throw new Error('目标项目没有可用的看板列')
const sourceColumns = await getColumns(source.projectId)
const targetMemberIds = new Set((await db().getAll('SELECT userId FROM ProjectMember WHERE projectId = ?', [String(targetProject.id)])).map((r) => String(r.userId)))
const pickColumn = (sourceColumnId) => {
const src = sourceColumns.find((c) => String(c.id) === String(sourceColumnId))
if (src) {
const same = targetColumns.find((c) => c.name === src.name)
if (same) return String(same.id)
}
return String(targetColumns[0]?.id)
}
const copyOne = async (tx, s, parentId) => {
const newId = uuid()
const ts = now()
await tx.execute(
`INSERT INTO Task (id, uuid, projectId, columnId, parentId, title, description, kind, markDate, status, doneAt, assigneeId, creatorId, extras, sortOrder, number, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[newId, newId, String(targetProject.id), pickColumn(s.columnId), parentId, s.title, s.description || '', s.kind || null,
s.markDate || null, s.status, s.status === 'done' ? (s.doneAt || ts) : null,
s.assigneeId && targetMemberIds.has(String(s.assigneeId)) ? String(s.assigneeId) : null,
uid, s.extras || '[]', Number(s.sortOrder) || 0, null, ts, ts],
)
const tagRows = await tx.getAll('SELECT name FROM TaskTag WHERE taskId = ?', [String(s.id)])
for (const t of tagRows) {
await tx.execute('INSERT INTO TaskTag (id, uuid, taskId, name) VALUES (?, ?, ?, ?)', [uuid(), uuid(), newId, t.name])
}
const keptMembers = (await tx.getAll('SELECT userId FROM TaskMember WHERE taskId = ?', [String(s.id)])).map((r) => String(r.userId)).filter((x) => targetMemberIds.has(x))
for (const m of keptMembers) {
await tx.execute('INSERT INTO TaskMember (id, uuid, taskId, userId) VALUES (?, ?, ?, ?)', [uuid(), uuid(), newId, m])
}
return newId
}
const newId = await db().writeTransaction(async (tx) => {
const pid = await copyOne(tx, source, null)
const children = await tx.getAll('SELECT * FROM Task WHERE parentId = ?', [String(source.id)])
for (const child of children) await copyOne(tx, child, pid)
return pid
})
const row = await db().get('SELECT * FROM Task WHERE id = ?', [newId])
const shape = await shapeTasks([row], targetColumns)
return { item: shape(row), dropped: { assignee: null, members: [] } }
},
// ---------- 归档 (T-ARC, 本地实现: 写本地库 -> 上行) ----------
/** 手动归档: 连带全部子任务, 幂等 (T-ARC-01) */
archive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const now = now()
await db().writeTransaction(async (tx) => {
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',')
await tx.execute(
`UPDATE Task SET archived = 1, archivedAt = ?, archiveSource = 'manual', updatedAt = ? WHERE id IN (${ph})`,
[now, now, ...ids],
)
})
return { ok: true, count: 1 }
},
/** 恢复归档: 回原列, 状态不变, 连带子任务 (T-ARC-03) */
unarchive: async (id) => {
const task = await db().getOptional('SELECT * FROM Task WHERE id = ?', [String(id)])
if (!task) throw new Error('任务不存在或无权限')
const now = now()
await db().writeTransaction(async (tx) => {
const kids = await tx.getAll('SELECT id FROM Task WHERE parentId = ?', [String(task.id)])
const ids = [String(task.id), ...kids.map((k) => String(k.id))]
const ph = ids.map(() => '?').join(',')
await tx.execute(
`UPDATE Task SET archived = 0, archivedAt = NULL, archiveSource = NULL, updatedAt = ? WHERE id IN (${ph})`,
[now, ...ids],
)
})
return { ok: true, count: 1 }
},
/**
* 已归档查询: 关键词 / 类型 / 列 / 归档日期区间 + 游标分页 (T-ARC-04)
* 参数: { keyword, kind, columnId, from, to, limit, cursor }
* cursor 形如 "<archivedAt ISO>|<id>", 按 (archivedAt desc, id desc) 取下一页
*/
archivedList: async (projectId, params = {}) => {
const columns = await getColumns(projectId)
const p = params || {}
const limit = Math.min(100, Math.max(1, Number(p.limit) || 20))
const where = ['projectId = ?', 'archived = 1', 'parentId IS NULL']
const args = [String(projectId)]
if (p.keyword) {
where.push('title LIKE ?')
args.push(`%${String(p.keyword).trim()}%`)
}
if (p.kind) {
where.push('kind = ?')
args.push(String(p.kind))
}
if (p.columnId) {
where.push('columnId = ?')
args.push(String(p.columnId))
}
if (p.from) {
where.push('archivedAt >= ?')
args.push(String(p.from) + 'T00:00:00+08:00'.replace('+08:00', 'Z'))
}
if (p.to) {
where.push('archivedAt <= ?')
args.push(String(p.to) + 'T23:59:59.999Z')
}
if (p.cursor) {
const [ts, cid] = String(p.cursor).split('|')
where.push('(archivedAt < ? OR (archivedAt = ? AND id < ?))')
args.push(ts, ts, String(cid))
}
const rows = await db().getAll(
`SELECT * FROM Task WHERE ${where.join(' AND ')} ORDER BY archivedAt DESC, id DESC LIMIT ${limit + 1}`,
args,
)
const hasMore = rows.length > limit
const page = rows.slice(0, limit)
const shape = await shapeTasks(page, columns)
const items = page.map(shape)
const last = page[page.length - 1]
const nextCursor = hasMore && last ? `${last.archivedAt}|${last.id}` : null
return { items, nextCursor }
},
}