fix(任务): 修复列表与看板拖拽「能触发但放不下」—— uuid 主键被 Number() 成 NaN
Docker Build and Push / build-image (push) Successful in 1m34s

0.2.1 切本地库后 Task.id / BoardColumn.id 为 uuid 字符串, 拖拽落点却用
Number(dataset.xxxId) 取值得到 NaN:
  - 看板 hitColumn 返回 NaN -> NaN 为 falsy, onDrop 永不触发; 目标列也不高亮
  - 列表 colId/taskId 为 NaN -> 组内命中与换列 findIndex 恒失败, 松手无操作
(打卡侧仍走 REST 数字 id, 故未受影响)

- 新增 src/pages/task/ids.js: idStr / sameId / datasetId, 统一按字符串比对
- useColumnDrag / useRowDrag: 落点、组内命中、换列、提交判定改用 sameId
- ProjectView: moveToColumn / reorderTask / changeStatus 改用 sameId,
  落点为空直接忽略, 不把坏 id 写进本地库
- api/task.js: update 拦截 "NaN"/"undefined" 列 id, 报「看板列无效」

验证: headless Edge + CDP 派发真实指针事件驱动两个 hook (uuid 数据),
修复前 4 条落点断言全挂, 修复后全部通过。
This commit is contained in:
jif2.zhang
2026-10-27 10:06:37 +08:00
parent 06e9a3e694
commit 32f404ac90
5 changed files with 58 additions and 19 deletions
+8 -1
View File
@@ -536,10 +536,17 @@ export const taskApi = {
} }
// 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径) // 列 / 状态联动裁决 (与 server/task-domain/resolveStatus.js 同口径)
// 本地库主键是 uuid 字符串: 若调用方把 id 过了一道 Number() 会变成 NaN / "NaN",
// 这里直接拦下并给出可定位的错误, 而不是让后续比对静默失败
let status = task.status let status = task.status
let columnId = String(task.columnId) let columnId = String(task.columnId)
const patch = {} const patch = {}
if (body?.columnId !== undefined) patch.columnId = String(body.columnId) 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 (body?.status !== undefined) {
if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法') if (!['todo', 'doing', 'done'].includes(body.status)) throw new Error('任务状态取值非法')
patch.status = body.status patch.status = body.status
+4 -2
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { datasetId, sameId } from '../../pages/task/ids.js'
// 看板卡片列拖动 (重点设计 §7.1) // 看板卡片列拖动 (重点设计 §7.1)
// 方案要点: // 方案要点:
@@ -60,10 +61,11 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */ /** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, []) const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
// 命中列 id: 本地库列 id 是 uuid 字符串, 不能 Number() (会变 NaN -> 落不下去)
const hitColumn = (x, y) => { const hitColumn = (x, y) => {
const el = document.elementFromPoint(x, y) const el = document.elementFromPoint(x, y)
const colEl = el?.closest?.('[data-column-id]') const colEl = el?.closest?.('[data-column-id]')
return colEl ? Number(colEl.dataset.columnId) : null return colEl ? datasetId(colEl.dataset.columnId) : null
} }
// 边缘自动滚动 (±36px 触发, 每帧 10px) // 边缘自动滚动 (±36px 触发, 每帧 10px)
@@ -105,7 +107,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
if (s.active) { if (s.active) {
lastEnd.current = Date.now() lastEnd.current = Date.now()
const col = hitColumn(ev.clientX, ev.clientY) const col = hitColumn(ev.clientX, ev.clientY)
if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col) if (col && s.task && !sameId(col, s.task.columnId)) onDropRef.current?.(s.task, col)
} }
cleanup() cleanup()
} }
+9 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { datasetId, sameId } from '../../pages/task/ids.js'
// 列表视图任务行拖动: 组间换列 + 组内排序 // 列表视图任务行拖动: 组间换列 + 组内排序
// 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart // 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart
@@ -78,15 +79,15 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
moveGhost(ev.clientX, ev.clientY) moveGhost(ev.clientX, ev.clientY)
// 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行 // 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行
// 本地库的列/任务 id 均为 uuid 字符串 -> 一律按字符串比对, 不要 Number()
const hitEl = document.elementFromPoint(ev.clientX, ev.clientY) const hitEl = document.elementFromPoint(ev.clientX, ev.clientY)
const rowEl = hitEl?.closest?.('[data-task-id]') const rowEl = hitEl?.closest?.('[data-task-id]')
const colEl = hitEl?.closest?.('[data-column-id]') const colEl = hitEl?.closest?.('[data-column-id]')
const colId = colEl ? Number(colEl.dataset.columnId) : null const colId = colEl ? datasetId(colEl.dataset.columnId) : null
const curCol = orderedRef.current[s.toIdx]?.id
if (rowEl && colId === s.task.columnId) { if (rowEl && sameId(colId, s.task.columnId)) {
const id = Number(rowEl.dataset.taskId) const id = datasetId(rowEl.dataset.taskId)
if (id !== s.task.id) { if (id && !sameId(id, s.task.id)) {
const r = rowEl.getBoundingClientRect() const r = rowEl.getBoundingClientRect()
const hit = { id, after: ev.clientY > r.top + r.height / 2 } const hit = { id, after: ev.clientY > r.top + r.height / 2 }
if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) { if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) {
@@ -100,7 +101,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
// 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标 // 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标
if (colId != null) { if (colId != null) {
const to = orderedRef.current.findIndex((c) => c.id === colId) const to = orderedRef.current.findIndex((c) => sameId(c.id, colId))
if (to >= 0 && to !== s.toIdx) { if (to >= 0 && to !== s.toIdx) {
s.toIdx = to s.toIdx = to
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to }) setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
@@ -116,7 +117,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
onReorderRef.current?.(s.task, s.over.id, s.over.after) onReorderRef.current?.(s.task, s.over.id, s.over.after)
} else { } else {
const target = orderedRef.current[s.toIdx] const target = orderedRef.current[s.toIdx]
if (target && target.id !== s.task.columnId) onDropRef.current?.(s.task, target.id) if (target && !sameId(target.id, s.task.columnId)) onDropRef.current?.(s.task, target.id)
} }
} }
cleanup() cleanup()
@@ -152,7 +153,7 @@ export default function useRowDrag({ columns, onDrop, onReorder }) {
} }
const startDrag = (ev, task, instant) => { const startDrag = (ev, task, instant) => {
const fromIdx = orderedRef.current.findIndex((c) => c.id === task.columnId) const fromIdx = orderedRef.current.findIndex((c) => sameId(c.id, task.columnId))
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动 if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
st.current = { st.current = {
...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx,
+11 -8
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile' import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js' import { projectApi, taskApi } from '../../api/task.js'
import { filterVisible, msToNextBoundary } from './date.js' import { filterVisible, msToNextBoundary } from './date.js'
import { sameId } from './ids.js'
import { getViewMode, setViewMode } from './prefs.js' import { getViewMode, setViewMode } from './prefs.js'
import { parseTimePrefix, splitTitle } from './quickAdd.js' import { parseTimePrefix, splitTitle } from './quickAdd.js'
import ListView from './ListView.jsx' import ListView from './ListView.jsx'
@@ -100,10 +101,12 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
/** 拖拽换列 (T-BD-04/07) */ /** 拖拽换列 (T-BD-04/07) */
const moveToColumn = async (task, columnId) => { const moveToColumn = async (task, columnId) => {
const col = columns.find((c) => c.id === columnId) // columnId 来自拖动落点 (uuid 字符串); 空值直接忽略, 不要把坏 id 写进本地库
if (columnId === null || columnId === undefined || columnId === '') return
const col = columns.find((c) => sameId(c.id, columnId))
try { try {
await updateTask(task.id, { columnId }) await updateTask(task.id, { columnId })
const target = columns.find((c) => c.id === columnId) const target = columns.find((c) => sameId(c.id, columnId))
if (target?.boundStatus && target.boundStatus !== task.status) { if (target?.boundStatus && target.boundStatus !== task.status) {
Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` }) Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` })
} else { } else {
@@ -116,11 +119,11 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const reorderTask = async (task, targetId, after) => { const reorderTask = async (task, targetId, after) => {
const snapshot = tasks const snapshot = tasks
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder) const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
const groups = orderedCols.map((c) => (tasks || []).filter((t) => t.columnId === c.id)).filter((l) => l.length) const groups = orderedCols.map((c) => (tasks || []).filter((t) => sameId(t.columnId, c.id))).filter((l) => l.length)
const g = groups.find((l) => l.some((t) => t.id === task.id)) const g = groups.find((l) => l.some((t) => sameId(t.id, task.id)))
if (!g) return if (!g) return
const rest = g.filter((t) => t.id !== task.id) const rest = g.filter((t) => !sameId(t.id, task.id))
const ti = rest.findIndex((t) => t.id === targetId) const ti = rest.findIndex((t) => sameId(t.id, targetId))
if (ti < 0) return if (ti < 0) return
rest.splice(after ? ti + 1 : ti, 0, task) rest.splice(after ? ti + 1 : ti, 0, task)
const flat = groups.map((l) => (l === g ? rest : l)).flat() const flat = groups.map((l) => (l === g ? rest : l)).flat()
@@ -143,9 +146,9 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const changeStatus = async (task, status) => { const changeStatus = async (task, status) => {
try { try {
const fresh = await updateTask(task.id, { status }) const fresh = await updateTask(task.id, { status })
const moved = fresh && fresh.columnId !== task.columnId const moved = fresh && !sameId(fresh.columnId, task.columnId)
if (moved) { if (moved) {
const col = columns.find((c) => c.id === fresh.columnId) const col = columns.find((c) => sameId(c.id, fresh.columnId))
Toast.show({ content: `已移至 ${col?.name || '目标列'}` }) Toast.show({ content: `已移至 ${col?.name || '目标列'}` })
} }
} catch { /* ignore */ } } catch { /* ignore */ }
+26
View File
@@ -0,0 +1,26 @@
// 任务模块: 主键比对工具
//
// 为什么要单独一个模块:
// 0.2.1 把任务模块切到 PowerSync 本地库后, 主键不再是数据库自增整数,
// 而是 **uuid 字符串** (`Task.id` / `BoardColumn.id` / `Project.id` 均为 uuid)。
// 旧代码里大量 `Number(el.dataset.xxxId)` / `Number(key)` 的写法会得到 NaN,
// 而 NaN 与任何值都不相等 (且为 falsy), 表现为「拖拽能激活但松手落不下去」
// 「换列提示看板列不存在」这类静默失败。
// 凡是从 DOM dataset / ActionSheet key / 本地存储读回来的 id, 一律走这里比对。
/** 统一成字符串再比较; 空值 (null/undefined/'') 返回 '' */
export function idStr(v) {
return v === null || v === undefined ? '' : String(v)
}
/** 两个 id 是否相同 (uuid 字符串 / 数字都适用) */
export function sameId(a, b) {
const x = idStr(a)
return x !== '' && x === idStr(b)
}
/** DOM dataset 里的 id: 空/缺失返回 null, 便于 `if (id)` 判空 */
export function datasetId(v) {
const s = idStr(v)
return s === '' || s === 'undefined' || s === 'null' ? null : s
}