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
+144 -11
View File
@@ -1,13 +1,121 @@
// API 调用封装: 自动带 JWT、401 自动清 token、抛统一错误
// 0.2.1 离线支持:
// - GET 请求成功后把响应写入本地缓存 (localStorage), 离线/网络失败时回退缓存 (界面仍有数据)
// - 写请求 (POST/PUT/DELETE) 仍要求在线 (任务模块的离线写走 PowerSync 上传队列)
// - 统一在线/离线状态订阅, 供 UI 显示离线标识
const TOKEN_KEY = 'iboard_token'
const REMEMBER_KEY = 'iboard_remember'
const UID_KEY = 'iboard_uid'
// API 基址:
// - 默认相对路径 `/api` (web dev: Vite 代理; web prod: 同源 Express 一起托管)
// - 打包 Android 时设 VITE_API_BASE=http://bh.vps.honor3.com:3001 走外网后端
// - 打包 Android / Windows 外壳时设 VITE_API_BASE=http://bh.vps.honor3.com:3001 走外网后端
const API_BASE = (import.meta.env.VITE_API_BASE || '').replace(/\/+$/, '')
const API_PREFIX = API_BASE ? API_BASE + '/api' : '/api'
// ---------- 在线/离线状态 (网络事件 + 请求结果联动) ----------
let offline = typeof navigator !== 'undefined' ? navigator.onLine === false : false
const offlineListeners = new Set()
export function isOffline() {
return offline
}
function setOffline(next) {
if (offline === next) return
offline = next
offlineListeners.forEach((cb) => {
try { cb(next) } catch { /* ignore */ }
})
}
if (typeof window !== 'undefined') {
window.addEventListener('online', () => setOffline(false))
window.addEventListener('offline', () => setOffline(true))
}
/** 订阅在线/离线变化, 返回取消函数 */
export function subscribeOffline(cb) {
offlineListeners.add(cb)
return () => offlineListeners.delete(cb)
}
// ---------- GET 响应本地缓存 ----------
// 目的: 任何界面只要成功访问过一次, 离线时都能展示上次的数据
const CACHE_PREFIX = 'iboard_cache:'
const CACHE_TOTAL_LIMIT = 3 * 1024 * 1024 // 约 3MB (localStorage 通常 5MB)
const CACHE_ENTRY_LIMIT = 512 * 1024 // 单条上限, 超大响应不缓存
function cacheKey(path) {
return CACHE_PREFIX + path
}
function readCache(path) {
try {
const raw = localStorage.getItem(cacheKey(path))
if (!raw) return null
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== 'object' || !('data' in parsed)) return null
return parsed
} catch {
return null
}
}
function cacheSizeUsed() {
let total = 0
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k && k.startsWith(CACHE_PREFIX)) total += (localStorage.getItem(k) || '').length
}
} catch { /* ignore */ }
return total
}
function evictOldest(needBytes) {
try {
const entries = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (!k || !k.startsWith(CACHE_PREFIX)) continue
let ts = 0
try { ts = JSON.parse(localStorage.getItem(k) || '{}').ts || 0 } catch { /* ignore */ }
entries.push({ k, ts })
}
entries.sort((a, b) => a.ts - b.ts)
let used = cacheSizeUsed()
for (const e of entries) {
if (used + needBytes <= CACHE_TOTAL_LIMIT) break
used -= (localStorage.getItem(e.k) || '').length
localStorage.removeItem(e.k)
}
} catch { /* ignore */ }
}
function writeCache(path, data) {
try {
const payload = JSON.stringify({ ts: Date.now(), data })
if (payload.length > CACHE_ENTRY_LIMIT) return // 过大不缓存
evictOldest(payload.length)
localStorage.setItem(cacheKey(path), payload)
} catch {
// 配额满等异常: 静默失败, 不影响主流程
}
}
/** 清空所有 GET 缓存 (退出登录时调用) */
export function clearApiCache() {
try {
const keys = []
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i)
if (k && k.startsWith(CACHE_PREFIX)) keys.push(k)
}
keys.forEach((k) => localStorage.removeItem(k))
} catch { /* ignore */ }
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
@@ -26,21 +134,45 @@ export function setRememberFlag(v) {
else localStorage.removeItem(REMEMBER_KEY)
}
// 当前用户 id (0.2.1 离线写入需要 ownerId/creatorId, 本地库无法即时从服务端获取)
export function getCurrentUserId() {
return localStorage.getItem(UID_KEY) || ''
}
export function setCurrentUserId(id) {
if (id !== null && id !== undefined && id !== '') localStorage.setItem(UID_KEY, String(id))
else localStorage.removeItem(UID_KEY)
}
export function getApiBase() {
return API_BASE
}
export async function api(path, { method = 'GET', body, headers = {} } = {}) {
const token = getToken()
const res = await fetch(API_PREFIX + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
const isRead = method === 'GET'
let res
try {
res = await fetch(API_PREFIX + path, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
})
} catch {
// 网络层失败: 离线 / 服务不可达
setOffline(true)
if (isRead) {
const cached = readCache(path)
if (cached) return cached.data
throw new Error('当前离线,且该界面尚无本地缓存')
}
throw new Error('当前离线,修改操作需要联网后重试')
}
setOffline(false)
if (res.status === 401) {
setToken('')
const data = await res.json().catch(() => ({}))
@@ -49,5 +181,6 @@ export async function api(path, { method = 'GET', body, headers = {} } = {}) {
}
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || '请求失败 (' + res.status + ')')
if (isRead) writeCache(path, data)
return data
}
}
+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 }
},
}