Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86335dfaa4 | ||
|
|
a23b10959c |
@@ -7,6 +7,7 @@ import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '../db.js'
|
||||
import { JWT_SECRET, authRequired } from '../middleware.js'
|
||||
import { genUuid, normProjectCode, allocProjectCode, withTaskNumber } from '../task-domain/identity.js'
|
||||
import { resolveTaskUpload } from '../task-domain/resolveStatus.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -96,6 +97,17 @@ async function applyPut(op, ctx) {
|
||||
if (d.taskKinds !== undefined) {
|
||||
data.taskKinds = Array.isArray(d.taskKinds) ? d.taskKinds.map((k) => String(k).slice(0, 20)).filter(Boolean) : null
|
||||
}
|
||||
// 项目图标: data URL (客户端已裁剪为 96x96 JPEG); 校验口径同 REST 写接口
|
||||
if (d.icon !== undefined) {
|
||||
if (d.icon === null || d.icon === '') {
|
||||
data.icon = null
|
||||
} else {
|
||||
const s = String(d.icon)
|
||||
if (!s.startsWith('data:image/')) throw new Error('图标格式非法')
|
||||
if (s.length > 300_000) throw new Error('图标过大')
|
||||
data.icon = s
|
||||
}
|
||||
}
|
||||
if (Object.keys(data).length) await prisma.project.update({ where: { uuid }, data })
|
||||
return {}
|
||||
}
|
||||
@@ -183,18 +195,43 @@ async function applyPut(op, ctx) {
|
||||
if (d.extras !== undefined) data.extras = cleanExtras(d.extras)
|
||||
if (d.sortOrder !== undefined) data.sortOrder = Number(d.sortOrder) || 0
|
||||
if (d.assigneeId !== undefined) data.assigneeId = d.assigneeId ? Number(d.assigneeId) : null
|
||||
if (d.doneAt !== undefined) data.doneAt = isoTime(d.doneAt)
|
||||
// 归档状态 (T-ARC): 客户端本地归档/恢复走这里
|
||||
if (d.archived !== undefined) data.archived = !!d.archived
|
||||
if (d.archivedAt !== undefined) data.archivedAt = isoTime(d.archivedAt)
|
||||
if (d.archiveSource !== undefined) {
|
||||
data.archiveSource = ['manual', 'auto'].includes(d.archiveSource) ? d.archiveSource : null
|
||||
}
|
||||
|
||||
// 列 / 状态: 复用 REST 写接口的同一裁决口径 (server/task-domain/resolveStatus.js)
|
||||
// 客户端上行的是本地整行 (含 status 与 columnId), 变更检测与裁决
|
||||
// 统一封装在 resolveTaskUpload 里 (那边有详细注释与用例)
|
||||
let nextColumnId
|
||||
if (d.columnId !== undefined) {
|
||||
const col = await prisma.boardColumn.findUnique({ where: { uuid: d.columnId } })
|
||||
if (!col || col.projectId !== existing.projectId) throw new Error('看板列不存在')
|
||||
data.columnId = col.id
|
||||
nextColumnId = col.id
|
||||
}
|
||||
if (d.status !== undefined && !TASK_STATUSES.includes(d.status)) {
|
||||
throw new Error('任务状态取值非法')
|
||||
}
|
||||
const resolved = resolveTaskUpload(
|
||||
existing,
|
||||
{ columnId: nextColumnId, status: d.status },
|
||||
await ctx.loadColumns(existing.projectId),
|
||||
)
|
||||
if (resolved) {
|
||||
data.status = resolved.status
|
||||
data.columnId = resolved.columnId
|
||||
// doneAt 与最终状态保持一致 (口径同 REST 写接口): 进入 done 记时, 离开 done 清空
|
||||
if (resolved.status === 'done') {
|
||||
if (existing.status !== 'done') data.doneAt = isoTime(d.doneAt) || new Date()
|
||||
} else {
|
||||
data.doneAt = null
|
||||
}
|
||||
} else if (d.doneAt !== undefined) {
|
||||
data.doneAt = isoTime(d.doneAt)
|
||||
}
|
||||
|
||||
if (d.parentId !== undefined) {
|
||||
if (!d.parentId) {
|
||||
data.parentId = null
|
||||
@@ -355,7 +392,19 @@ router.post('/upload', authRequired, async (req, res, next) => {
|
||||
memberCache.set(projectId, ok)
|
||||
return ok
|
||||
}
|
||||
const ctx = { userId, assertMember }
|
||||
// 项目列缓存: 列/状态裁决需要全部列; 同批内按项目缓存 (ops 已按表序排列,
|
||||
// BoardColumn 先于 Task 落地, 故 Task 阶段读到的列是本次批次的最终结果)
|
||||
const columnCache = new Map()
|
||||
const loadColumns = async (projectId) => {
|
||||
if (columnCache.has(projectId)) return columnCache.get(projectId)
|
||||
const rows = await prisma.boardColumn.findMany({
|
||||
where: { projectId },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
columnCache.set(projectId, rows)
|
||||
return rows
|
||||
}
|
||||
const ctx = { userId, assertMember, loadColumns }
|
||||
|
||||
// 依赖顺序: 父表先落地 (Project → BoardColumn → ProjectMember → Task → TaskMember/TaskTag); 删除反向
|
||||
const rank = (o) => TABLE_RANK[o?.table] ?? 99
|
||||
|
||||
@@ -83,3 +83,32 @@ export function resolveTaskWrite(task, patch, columns) {
|
||||
|
||||
return { status, columnId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 离线同步上传路径专用: 客户端上行的是**本地整行**, 字段总是存在, 因此不能用
|
||||
* "字段是否存在"判断变更 —— 必须先与服务端现值比较, 得出"这次真正改了什么",
|
||||
* 再交给 resolveTaskWrite 裁决。
|
||||
*
|
||||
* 不这样做会踩的坑 (T-BD-16): 任务从 Plan 拖到未绑定状态的 Action 列, 客户端
|
||||
* 整行里 status 仍是 todo; 若把 status 当作"本次变更"提交, 场景 B 会把任务从
|
||||
* Action 归位回第一个绑定 todo 的 Plan —— 用户拖到 Action 又被弹回去。
|
||||
*
|
||||
* @param {object} existing 服务端现有任务 (含 columnId/status/parentId)
|
||||
* @param {object} incoming 本次上行值 { columnId?: number, status?: string }
|
||||
* 外键需调用方先由 uuid 解析为内部 int id
|
||||
* @param {object[]} columns 项目全部列
|
||||
* @returns {{ status: string, columnId: number } | null} null = 列/状态无变化
|
||||
*/
|
||||
export function resolveTaskUpload(existing, incoming, columns) {
|
||||
const nextColumnId = incoming?.columnId !== undefined ? incoming.columnId : existing.columnId
|
||||
const patch = {}
|
||||
if (nextColumnId !== existing.columnId) patch.columnId = nextColumnId
|
||||
if (incoming?.status !== undefined && incoming.status !== existing.status) patch.status = incoming.status
|
||||
if (!Object.keys(patch).length) return null
|
||||
|
||||
// task.column 必须与 columnId 一起指向目标列, 否则场景 B 的
|
||||
// "当前列已匹配该状态" 会拿旧列判断, 同样导致误归位
|
||||
const targetCol = (columns || []).find((c) => c.id === nextColumnId) || null
|
||||
const r = resolveTaskWrite({ ...existing, columnId: nextColumnId, column: targetCol }, patch, columns)
|
||||
return { status: r.status, columnId: r.columnId }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ export function getPowersyncDb() {
|
||||
database: {
|
||||
// 默认 IDBBatchAtomicVFS (IndexedDB), 兼容性最广; WebView2 (Chromium) 后续可切 OPFS 加速
|
||||
dbFilename: 'iboard-powersync.db',
|
||||
// 关闭多标签支持 —— 否则桌面端 (WebView2/Chromium) 会启用 **共享 IndexedDB worker**:
|
||||
// enableMultiTabs 默认为 true (桌面且有 SharedWorker 时), 该 SharedWorker 会跨页面
|
||||
// reload 存活; OTA 升级后 reload 的新页面连上去时本地库读取会永久挂起
|
||||
// (无异常、纯 pending) —— 表现为任务页一直「加载中」, 其它不碰本地库的页面正常。
|
||||
// Android WebView 不支持 SharedWorker, 因此只有 Windows 外壳命中。
|
||||
// 本应用是单窗口外壳 (dock.html 不访问本地库), 不需要多标签同步。
|
||||
enableMultiTabs: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user