Docker Build and Push / build-image (push) Successful in 1m34s
现象: 项目设置 -> 归档设置 -> 隐藏的任务自动归档, 选完档位提示「已保存」,
重新打开仍是「永不」, 且不会产生任何归档。
根因 (读写两条路都断了, 缺一不可):
1. 读: projectSummary() 没有回传 autoArchive, 而 ProjectManage 读的是
project.autoArchive -> 永远是 undefined -> 界面恒显示默认值「永不」;
2. 写: projectApi.update() 的字段白名单里没有 autoArchive 分支
(只有 name/code/taskKinds/icon/archived/notes), 传进来直接被忽略,
data 为空 -> 一条 SQL 都不执行 -> 但函数正常返回, 调用方照常弹「已保存」。
服务端一直是对的: powersync 上传分支处理了 autoArchive, REST 也有校验,
所以不是同步丢字段。
修复:
- projectSummary 增加 autoArchive: p.autoArchive || 'never';
- projectApi.update 增加 autoArchive 分支, 校验取值与 AUTO_ARCHIVE_OPTIONS 同口径。
验证 (真实 Windows 外壳, 临时暴露 API 直调被修改的函数):
- update({autoArchive:'3d'}) 返回 3d; 重新 list/detail 仍为 3d (已落库);
- 进项目设置, 该行显示「一周后 ›」(设成 1w 后), 不再恒为「永不」;
- 还原为 never 后界面同步回「永不」。
- 顺带确认「同样隐藏已完成」的列级设置 retention 读写都正常:
Done 列 retention=3d, 服务端 26 条已完成中 9 条超 3 天,
App 实际只返回 14 条 -> 过滤本身是生效的, 此前不生效的只有自动归档档位。
- 控制台无异常; 验证用的临时钩子已移除。
809 lines
38 KiB
JavaScript
809 lines
38 KiB
JavaScript
// 任务模块数据层 (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
|
|
|
|
// 即时同步: 订阅用的本地表集合 (见 docs/V0.2/离线编辑模块/即时同步方案.md)
|
|
// TASK_TABLES 影响"某项目的任务列表/看板"的内容
|
|
// PROJECT_TABLES 影响"项目列表"的内容
|
|
// 只用于变更通知, 不参与查询; 表名必须与 PowerSync 客户端 schema 一致。
|
|
export const TASK_TABLES = ['Task', 'BoardColumn', 'TaskMember', 'TaskTag']
|
|
export const PROJECT_TABLES = ['Project', 'ProjectMember']
|
|
|
|
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,
|
|
// 自动归档档位 (T-ARC-02): 必须回传给前端, 否则该设置在界面上永远显示默认的「永不」
|
|
autoArchive: p.autoArchive || 'never',
|
|
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),
|
|
})))
|
|
}
|
|
if (body?.autoArchive !== undefined) {
|
|
// 自动归档档位 (T-ARC-02): 与服务端 AUTO_ARCHIVE_OPTIONS 同口径
|
|
if (!['3d', '1w', '1m', 'never'].includes(body.autoArchive)) throw new Error('自动归档档位非法')
|
|
data.autoArchive = body.autoArchive
|
|
}
|
|
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 同口径)
|
|
// 本地库主键是 uuid 字符串: 若调用方把 id 过了一道 Number() 会变成 NaN / "NaN",
|
|
// 这里直接拦下并给出可定位的错误, 而不是让后续比对静默失败
|
|
let status = task.status
|
|
let columnId = String(task.columnId)
|
|
const patch = {}
|
|
if (body?.columnId !== undefined) {
|
|
const cid = body.columnId === null ? '' : String(body.columnId)
|
|
if (!cid) throw new Error('看板列不能为空')
|
|
if (cid === 'NaN' || cid === 'undefined') throw new Error('看板列无效(编号已损坏)')
|
|
patch.columnId = cid
|
|
}
|
|
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(): 同名 const 会遮蔽模块级 now, 右侧求值落入 TDZ,
|
|
// 压缩后报 "Cannot access 'a' before initialization"
|
|
const ts = 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})`,
|
|
[ts, ts, ...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 ts = 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})`,
|
|
[ts, ...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))
|
|
}
|
|
// 日期边界: 传 'YYYY-MM-DD' 时按**本地时区**的那一天换算成 UTC 瞬时再比较。
|
|
// (原来直接拼 'T00:00:00Z', 本地选的日期会偏移 8 小时, 导致当天凌晨的归档被漏掉)
|
|
const dayToIso = (v, endOfDay) => {
|
|
const s = String(v)
|
|
if (s.includes('T')) return s // 已带时间的 ISO, 原样使用
|
|
const d = new Date(`${s}T00:00:00`) // 本地 00:00
|
|
if (Number.isNaN(d.getTime())) return null
|
|
if (endOfDay) d.setHours(23, 59, 59, 999)
|
|
return d.toISOString()
|
|
}
|
|
if (p.from) {
|
|
const iso = dayToIso(p.from, false)
|
|
if (iso) { where.push('archivedAt >= ?'); args.push(iso) }
|
|
}
|
|
if (p.to) {
|
|
const iso = dayToIso(p.to, true)
|
|
if (iso) { where.push('archivedAt <= ?'); args.push(iso) }
|
|
}
|
|
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 }
|
|
},
|
|
}
|