From 5066007d9d912e30e5483db9bac6648972568edf Mon Sep 17 00:00:00 2001 From: "jif2.zhang" Date: Sun, 25 Oct 2026 02:07:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(V0.2):=20=E5=85=8B=E9=9A=86=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E8=B7=9F=E6=89=8B=E6=B5=AE=E5=B1=82=20+=20=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E7=BB=84=E5=86=85=E6=8E=92=E5=BA=8F=20+=20=E6=8A=8A?= =?UTF-8?q?=E6=89=8B=E7=BB=9F=E4=B8=80=E7=A7=BB=E5=88=B0=E6=9C=80=E5=B7=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 浮层改为激活时克隆原卡片 (DOM clone, 淡化+投影) 挂 body 顶层, 移动直接改 transform —— 替代蓝色信息条, 所见即所拖 - 任务列表支持组内排序: 拖到本组某行上/下半显示插入指示, 松手提交 POST /projects/:id/tasks/reorder (顶层任务按展平顺序重写 sortOrder); 拖到其他分组仍为换列 - ⠿ 把手统一移到卡片最左: 打卡 / 列表任务行 / 看板卡片 (看板新增把手 即按即拖路径, 与列表同款 touchstart preventDefault 双保险) - useColumnDrag onDrop 经 ref 桥接防陈旧闭包 --- server/routes/tasks.js | 21 ++++ src/api/task.js | 1 + src/components/checkin/useHabitDrag.js | 78 ++++++++------ src/components/task/TaskCard.jsx | 5 +- src/components/task/TaskRow.jsx | 18 ++-- src/components/task/useColumnDrag.js | 117 ++++++++++++++------ src/components/task/useRowDrag.js | 141 +++++++++++++++++-------- src/pages/checkin/MergedView.jsx | 13 +-- src/pages/task/BoardView.jsx | 1 + src/pages/task/ListView.jsx | 17 +-- src/pages/task/ProjectView.jsx | 23 ++++ src/styles/globals.css | 23 ++-- 12 files changed, 300 insertions(+), 158 deletions(-) diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 1b29944..062d6bd 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -124,6 +124,27 @@ router.post('/projects/:id/tasks', async (req, res, next) => { } catch (e) { next(e) } }) +// POST /api/projects/:id/tasks/reorder { ids: [顶层任务 id 的新顺序] } +// 按数组下标重写 sortOrder (组内拖动排序; 子任务不参与, 跟随父任务) +router.post('/projects/:id/tasks/reorder', async (req, res, next) => { + try { + const userId = req.user.sub + const project = await loadProjectForUser(req.params.id, userId) + if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) + const ids = Array.isArray((req.body || {}).ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [] + if (!ids.length) return res.status(400).json({ error: 'ids 不能为空' }) + const rows = await prisma.task.findMany({ + where: { projectId: project.id, id: { in: ids }, parentId: null }, + select: { id: true }, + }) + if (rows.length !== new Set(ids).size) return res.status(400).json({ error: '包含无效的任务' }) + await prisma.$transaction( + ids.map((id, i) => prisma.task.update({ where: { id }, data: { sortOrder: i } })), + ) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + // ---------- 单条任务 ---------- // GET /api/tasks/:id diff --git a/src/api/task.js b/src/api/task.js index b4b133f..1c88765 100644 --- a/src/api/task.js +++ b/src/api/task.js @@ -35,6 +35,7 @@ export const taskApi = { 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 } }), } diff --git a/src/components/checkin/useHabitDrag.js b/src/components/checkin/useHabitDrag.js index 3d4df57..a2f5c00 100644 --- a/src/components/checkin/useHabitDrag.js +++ b/src/components/checkin/useHabitDrag.js @@ -1,40 +1,54 @@ import { useCallback, useEffect, useRef, useState } from 'react' // 打卡项组内拖动排序 (MergedView 分组列表) -// 两条触发路径, 状态机共用: -// 1. 把手 (bindHandle, 推荐): 卡片右侧 ⠿ 把手 touch-action:none, -// 指针按下立即激活 —— 浏览器按规范完全不接管该触摸, 指针流不会被 -// 滚动抢占取消, 在 Android WebView 上 100% 可靠; -// 2. 长按 (bind): 长按 400ms 原位抬起(震动)后拖动。pan-y 下列表一旦 -// 可滚动, 手指纵向抖动即触发浏览器滚动接管 (pointercancel), 手势 -// 被取消 —— 该路径在整页可滚的场景不可靠, 仅作补充入口。 -// 拖动中 elementFromPoint 命中同组 [data-habit-row] 作为落点 (data-habit-period -// 必须与拖动项一致, 即只在 每天/每周/每月 组内排序), 按指针在目标卡 -// 上半/下半决定插到其前/后, 松手提交。 -// 激活后用非 passive touchmove preventDefault 尽力拦截滚动接管。 +// 触发路径: +// 1. 把手 (bindHandle, 推荐): 卡片左侧 ⠿ 把手 touch-action:none + +// 非 passive touchstart preventDefault —— 浏览器按规范完全不接管该 +// 触摸, 指针流不会被滚动抢占取消, Android WebView 上可靠; +// 2. 长按 (bind): 长按 400ms 激活。pan-y 下列表可滚时手指纵向抖动会 +// 触发滚动接管 (pointercancel), 仅作补充入口。 +// 拖动: 激活时克隆原卡片作为跟手浮层 (淡化), 原位保留虚线占位; +// elementFromPoint 命中同组 [data-habit-row] 作为落点 (data-habit-period +// 必须一致, 即只在 每天/每周/每月 组内排序), 按指针在目标卡上半/下半 +// 决定插到其前/后, 松手提交。 export default function useHabitDrag({ onDrop }) { const [dragItem, setDragItem] = useState(null) // 拖动中的打卡项 (视觉态) const [over, setOver] = useState(null) // { id, after } 落点 - const st = useRef({ timer: null, active: false, startX: 0, startY: 0, item: null, over: null }) + const st = useRef({ timer: null, active: false, startX: 0, startY: 0, item: null, over: null, srcEl: null, ghost: null }) const lastEnd = useRef(0) // onDrop 取"最新一次渲染"的闭包: bind/bindHandle 是空依赖 useCallback, // 若直接捕获 onDrop, 会固化首次渲染(数据未加载, groups=[])的落位函数, - // 松手时静默早退 —— 顺序永远不变。用 ref 桥接到实时实现。 + // 松手时静默早退。用 ref 桥接到实时实现。 const onDropRef = useRef(onDrop) onDropRef.current = onDrop - // 跟手浮层: 消费方渲染
, 拖动时 hook 直接改 - // transform (绕过 React 重渲染, 保证跟手流畅) - const ghostRef = useRef(null) + // 跟手浮层: 激活时克隆原卡片挂到 body (脱离页签滑动容器的 transform), + // 移动时直接改 transform, 不经 React 渲染 const moveGhost = (x, y) => { - if (ghostRef.current) ghostRef.current.style.transform = `translate(${x}px, ${y}px) translate(-50%, -110%)` + const g = st.current.ghost + if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)` + } + const mountGhost = (x, y) => { + const src = st.current.srcEl + if (!src) return + const clone = src.cloneNode(true) + clone.classList.remove('dragging', 'dragover', 'after', 'before') + clone.style.width = `${src.offsetWidth}px` + const g = document.createElement('div') + g.className = 'drag-ghost' + g.appendChild(clone) + document.body.appendChild(g) + st.current.ghost = g + moveGhost(x, y) } const cleanup = useCallback(() => { if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } st.current.active = false st.current.over = null + st.current.srcEl = null + if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null } setDragItem(null) setOver(null) }, []) @@ -80,10 +94,7 @@ export default function useHabitDrag({ onDrop }) { cleanup() } - // 指针监听装配: instant=true 时按下即激活 (把手路径), 否则长按 400ms 激活。 - // 把手路径额外挂非 passive touchstart preventDefault —— 规范上这会禁止 - // 浏览器对"整个触摸"的一切默认处理 (含滚动), 比 touch-action:none 更强, - // 双保险防滚动抢占取消指针流。 + // 指针监听装配: instant=true 时按下即激活 (把手路径), 否则长按 400ms 激活 const armListeners = (ev, instant, item) => { try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } const el = ev.currentTarget @@ -109,26 +120,24 @@ export default function useHabitDrag({ onDrop }) { el.addEventListener('touchmove', tm, { passive: false }) if (instant) el.addEventListener('touchstart', ts, { passive: false }) - if (instant) { + const activate = () => { st.current.active = true setDragItem(item) - moveGhost(st.current.startX, st.current.startY) + mountGhost(st.current.startX, st.current.startY) if (navigator.vibrate) navigator.vibrate(15) - } else { - st.current.timer = setTimeout(() => { - st.current.active = true - setDragItem(item) - moveGhost(st.current.startX, st.current.startY) - if (navigator.vibrate) navigator.vibrate(15) - }, 400) } + if (instant) activate() + else st.current.timer = setTimeout(activate, 400) } /** 绑定到卡片: 卡片需 touch-action: pan-y (长按路径) */ const bind = useCallback((item) => ({ onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return - st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false, over: null } + st.current = { + ...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false, over: null, + srcEl: ev.currentTarget.closest('[data-habit-row]'), + } armListeners(ev, false, item) }, onContextMenu: (ev) => ev.preventDefault(), @@ -140,12 +149,15 @@ export default function useHabitDrag({ onDrop }) { onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return ev.stopPropagation() // 不触发卡片自身的长按路径 - st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false, over: null } + st.current = { + ...st.current, startX: ev.clientX, startY: ev.clientY, item, active: false, over: null, + srcEl: ev.currentTarget.closest('[data-habit-row]'), + } armListeners(ev, true, item) }, onContextMenu: (ev) => ev.preventDefault(), // eslint-disable-next-line react-hooks/exhaustive-deps }), []) - return { dragItem, over, bind, bindHandle, ghostRef, cancel: cleanup, justEnded } + return { dragItem, over, bind, bindHandle, cancel: cleanup, justEnded } } diff --git a/src/components/task/TaskCard.jsx b/src/components/task/TaskCard.jsx index c7ed88a..dec07c7 100644 --- a/src/components/task/TaskCard.jsx +++ b/src/components/task/TaskCard.jsx @@ -7,7 +7,7 @@ import StatusDot from './StatusDot.jsx' const MAX_SUB = 3 export default function TaskCard({ - task, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false, + task, bindDrag, bindDragHandle, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false, }) { const children = task.children || [] const shown = children.slice(0, MAX_SUB) @@ -20,6 +20,9 @@ export default function TaskCard({ onClick={onClick} {...(bindDrag ? bindDrag(task) : {})} > + {bindDragHandle && ( + ⠿ + )}
{task.title} (s === 'done' ? '完成' : s === 'doing' ? '执行中 // 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动, // 避免长按行后抬手时合成的 click 落在控件上误触状态切换 export default function TaskRow({ - task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragTarget, indent = false, children, + task, onToggle, onClick, onMore, bindDrag, bindDragHandle, dragTarget, dragOver, indent = false, children, }) { const done = task.status === 'done' return ( <>
+ {!indent && bindDragHandle && ( + ⠿ + )}
{ e.stopPropagation(); onToggle?.(task) }} @@ -58,13 +65,6 @@ export default function TaskRow({ → {dragTarget.name} )} - {bindDragHandle && ( - ⠿ - )} { e.stopPropagation(); onMore?.(task) }} diff --git a/src/components/task/useColumnDrag.js b/src/components/task/useColumnDrag.js index 2d46973..58d477f 100644 --- a/src/components/task/useColumnDrag.js +++ b/src/components/task/useColumnDrag.js @@ -14,9 +14,32 @@ import { useCallback, useEffect, useRef, useState } from 'react' export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) { const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态) const [overCol, setOverCol] = useState(null) // 高亮的目标列 id - const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 }) + const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0, srcEl: null, ghost: null }) const lastEnd = useRef(0) // 拖动结束时刻 + // onDrop 经 ref 桥接实时闭包 (bind 空依赖 useCallback, 防陈旧) + const onDropRef = useRef(onDrop) + onDropRef.current = onDrop + + // 跟手浮层: 激活时克隆原卡片挂到 body, 移动时直接改 transform + const moveGhost = (x, y) => { + const g = st.current.ghost + if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)` + } + const mountGhost = (x, y) => { + const src = st.current.srcEl + if (!src) return + const clone = src.cloneNode(true) + clone.classList.remove('dragging', 'hl') + clone.style.width = `${src.offsetWidth}px` + const g = document.createElement('div') + g.className = 'drag-ghost' + g.appendChild(clone) + document.body.appendChild(g) + st.current.ghost = g + moveGhost(x, y) + } + const stopAutoScroll = () => { st.current.autoScroll = 0 if (st.current.raf) { cancelAnimationFrame(st.current.raf); st.current.raf = 0 } @@ -25,6 +48,8 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } const cleanup = useCallback(() => { if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } st.current.active = false + st.current.srcEl = null + if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null } stopAutoScroll() setDragTask(null) setOverCol(null) @@ -69,6 +94,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } return } ev.preventDefault() + moveGhost(ev.clientX, ev.clientY) const col = hitColumn(ev.clientX, ev.clientY) setOverCol(col) autoScroll(document.querySelector(scrollSelector), ev.clientX) @@ -79,49 +105,76 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } if (s.active) { lastEnd.current = Date.now() const col = hitColumn(ev.clientX, ev.clientY) - if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col) + if (col && s.task && col !== s.task.columnId) onDropRef.current?.(s.task, col) } cleanup() } + // 指针监听装配 (capture + 非 passive touchmove; instant=true 把手路径 + // 再挂 touchstart preventDefault, 浏览器完全不接管该触摸) + const armListeners = (ev, instant) => { + try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } + const el = ev.currentTarget + const move = (e2) => onPointerMove(e2) + const tm = (e2) => { if (st.current.active) e2.preventDefault() } + const ts = (e2) => e2.preventDefault() + const up = (e2) => { onPointerUp(e2); detach() } + const cancel = () => { + if (st.current.active) lastEnd.current = Date.now() + cleanup() + detach() + } + const detach = () => { + el.removeEventListener('pointermove', move) + el.removeEventListener('pointerup', up) + el.removeEventListener('pointercancel', cancel) + el.removeEventListener('touchmove', tm) + el.removeEventListener('touchstart', ts) + } + el.addEventListener('pointermove', move) + el.addEventListener('pointerup', up) + el.addEventListener('pointercancel', cancel) + el.addEventListener('touchmove', tm, { passive: false }) + if (instant) el.addEventListener('touchstart', ts, { passive: false }) + } + + const startDrag = (ev, task, instant) => { + st.current = { + ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false, + srcEl: ev.currentTarget.closest('.bcard'), + } + const activate = () => { + st.current.active = true + setDragTask(task) + if (navigator.vibrate) navigator.vibrate(15) + mountGhost(st.current.startX, st.current.startY) + } + if (instant) activate() + else st.current.timer = setTimeout(activate, 400) + armListeners(ev, instant) + } + /** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */ const bind = useCallback((task) => ({ onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return - st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false } - // 长按计时: 期间移动超容差(用户在滚动)则取消 - st.current.timer = setTimeout(() => { - st.current.active = true - setDragTask(task) - if (navigator.vibrate) navigator.vibrate(15) - }, 400) - // 捕获: 后续 move/up/cancel 都派发给该元素, 不丢 - try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ } - const el = ev.currentTarget - const move = (e2) => onPointerMove(e2) - // 激活后拦截触摸滚动: 防纵向分量触发浏览器接管 (pointercancel), 同 useRowDrag - const tm = (e2) => { if (st.current.active) e2.preventDefault() } - const up = (e2) => { onPointerUp(e2); detach() } - const cancel = () => { - if (st.current.active) lastEnd.current = Date.now() - cleanup() - detach() - } - const detach = () => { - el.removeEventListener('pointermove', move) - el.removeEventListener('pointerup', up) - el.removeEventListener('pointercancel', cancel) - el.removeEventListener('touchmove', tm) - } - el.addEventListener('pointermove', move) - el.addEventListener('pointerup', up) - el.addEventListener('pointercancel', cancel) - el.addEventListener('touchmove', tm, { passive: false }) + startDrag(ev, task, false) }, // 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择) onContextMenu: (ev) => ev.preventDefault(), // eslint-disable-next-line react-hooks/exhaustive-deps }), []) - return { dragTask, overCol, bind, cancel: cleanup, justEnded } + /** 绑定到卡片左侧拖动把手: 把手需 touch-action: none, 按下立即拖动 */ + const bindHandle = useCallback((task) => ({ + onPointerDown: (ev) => { + if (ev.button !== undefined && ev.button !== 0) return + ev.stopPropagation() // 不触发卡片自身的长按路径 + startDrag(ev, task, true) + }, + onContextMenu: (ev) => ev.preventDefault(), + // eslint-disable-next-line react-hooks/exhaustive-deps + }), []) + + return { dragTask, overCol, bind, bindHandle, cancel: cleanup, justEnded } } diff --git a/src/components/task/useRowDrag.js b/src/components/task/useRowDrag.js index f8ad6be..c47943c 100644 --- a/src/components/task/useRowDrag.js +++ b/src/components/task/useRowDrag.js @@ -1,30 +1,64 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -// 列表视图卡片拖动换列 -// 触发: 行长按 400ms, 或按住行尾 ⠿ 把手 (touch-action:none, 立即激活, -// 把手不受浏览器滚动接管影响, 是可靠入口) -// 拖动: 跟手浮层 (消费方渲染), 手指划到哪个分组 ([data-column-id]) -// 即目标列, 松手落到目标列 (≠当前列才提交) +// 列表视图任务行拖动: 组间换列 + 组内排序 +// 触发: 行长按 400ms, 或按住行首 ⠿ 把手 (touch-action:none + touchstart +// preventDefault, 浏览器不接管该触摸, 是可靠入口) +// 拖动: 克隆原行作为跟手浮层 (淡化), 原位保留占位; +// 手指划到其他分组 ([data-column-id]) → 换列; 划到本组某行 +// ([data-task-id]) 的上/下半 → 组内插入到其前/后; 松手提交。 // touch-action: pan-y + 激活后非 passive touchmove preventDefault 尽力 -// 拦截滚动接管; pointercancel 只放弃不提交 +// 拦截滚动接管; pointercancel 只放弃不提交。 // 注: 列表只展示有任务的列, 空列拖不进去 (用看板视图可拖空列)。 -export default function useRowDrag({ columns, onDrop }) { - // { task, fromIdx, toIdx }: 拖动中的行 + 当前目标列下标 (徽标渲染) +export default function useRowDrag({ columns, onDrop, onReorder }) { + // { task, fromIdx, toIdx }: 拖动中的行 + 当前目标列下标 const [drag, setDrag] = useState(null) - const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, fromIdx: 0, toIdx: 0 }) + // { id, after }: 组内落点 (插入到某行前/后) + const [over, setOver] = useState(null) + const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, fromIdx: 0, toIdx: 0, over: null, srcEl: null, ghost: null }) const lastEnd = useRef(0) // 拖动结束时刻 + // onDrop/onReorder 经 ref 桥接实时闭包 (bind 是 useCallback, 防陈旧) + const onDropRef = useRef(onDrop) + onDropRef.current = onDrop + const onReorderRef = useRef(onReorder) + onReorderRef.current = onReorder + // 目标列顺序 = 列表分组顺序 (sortOrder) const ordered = useMemo( () => [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder), [columns], ) + const orderedRef = useRef(ordered) + orderedRef.current = ordered + + // 跟手浮层: 激活时克隆原行挂到 body, 移动时直接改 transform + const moveGhost = (x, y) => { + const g = st.current.ghost + if (g) g.style.transform = `translate(${x}px, ${y}px) translate(-50%, -60%)` + } + const mountGhost = (x, y) => { + const src = st.current.srcEl + if (!src) return + const clone = src.cloneNode(true) + clone.classList.remove('dragging', 'dragover', 'after', 'before') + clone.style.width = `${src.offsetWidth}px` + const g = document.createElement('div') + g.className = 'drag-ghost' + g.appendChild(clone) + document.body.appendChild(g) + st.current.ghost = g + moveGhost(x, y) + } const cleanup = useCallback(() => { if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null } st.current.active = false + st.current.over = null + st.current.srcEl = null + if (st.current.ghost) { st.current.ghost.remove(); st.current.ghost = null } setDrag(null) + setOver(null) }, []) useEffect(() => () => cleanup(), [cleanup]) @@ -32,12 +66,6 @@ export default function useRowDrag({ columns, onDrop }) { /** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */ const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, []) - // 跟手浮层: 消费方渲染
, 拖动时直接改 transform - const ghostRef = useRef(null) - const moveGhost = (x, y) => { - if (ghostRef.current) ghostRef.current.style.transform = `translate(${x}px, ${y}px) translate(-50%, -110%)` - } - const onMove = (ev) => { const s = st.current if (!s.active) { @@ -48,12 +76,31 @@ export default function useRowDrag({ columns, onDrop }) { } ev.preventDefault() moveGhost(ev.clientX, ev.clientY) - // 手指划到哪个分组([data-column-id])就是目标列 —— 与视觉一致, 松手即落。 - // 划出所有分组 (如页头/空白) 时保持上一个目标不变。 - const colEl = document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.('[data-column-id]') + + // 组内落点: 命中本组 ([data-column-id]=当前列) 的某条顶层行 + const hitEl = document.elementFromPoint(ev.clientX, ev.clientY) + const rowEl = hitEl?.closest?.('[data-task-id]') + const colEl = hitEl?.closest?.('[data-column-id]') const colId = colEl ? Number(colEl.dataset.columnId) : null + const curCol = orderedRef.current[s.toIdx]?.id + + if (rowEl && colId === s.task.columnId) { + const id = Number(rowEl.dataset.taskId) + if (id !== s.task.id) { + const r = rowEl.getBoundingClientRect() + const hit = { id, after: ev.clientY > r.top + r.height / 2 } + if (!s.over || s.over.id !== hit.id || s.over.after !== hit.after) { + s.over = hit + setOver(hit) + } + return + } + } + if (s.over) { s.over = null; setOver(null) } + + // 换列: 手指划到哪个分组就是目标列, 划出所有分组时保持上一目标 if (colId != null) { - const to = ordered.findIndex((c) => c.id === colId) + const to = orderedRef.current.findIndex((c) => c.id === colId) if (to >= 0 && to !== s.toIdx) { s.toIdx = to setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to }) @@ -65,8 +112,12 @@ export default function useRowDrag({ columns, onDrop }) { const s = st.current if (s.active && s.task) { lastEnd.current = Date.now() - const target = ordered[s.toIdx] - if (target && target.id !== s.task.columnId) onDrop?.(s.task, target.id) + if (s.over) { + onReorderRef.current?.(s.task, s.over.id, s.over.after) + } else { + const target = orderedRef.current[s.toIdx] + if (target && target.id !== s.task.columnId) onDropRef.current?.(s.task, target.id) + } } cleanup() } @@ -100,45 +151,45 @@ export default function useRowDrag({ columns, onDrop }) { if (instant) el.addEventListener('touchstart', ts, { passive: false }) } + const startDrag = (ev, task, instant) => { + const fromIdx = orderedRef.current.findIndex((c) => c.id === task.columnId) + if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动 + st.current = { + ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, + active: false, over: null, srcEl: ev.currentTarget.closest('.task-row'), + } + const activate = () => { + st.current.active = true + if (navigator.vibrate) navigator.vibrate(15) + setDrag({ task, fromIdx, toIdx: fromIdx }) + mountGhost(st.current.startX, st.current.startY) + } + if (instant) activate() + else st.current.timer = setTimeout(activate, 400) + armListeners(ev, instant) + } + /** 绑定到列表行: 行元素需设置 touch-action: pan-y (长按路径, 可滚场景不可靠) */ const bind = useCallback((task) => ({ onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return - const fromIdx = ordered.findIndex((c) => c.id === task.columnId) - if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动 - st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, active: false } - st.current.timer = setTimeout(() => { - st.current.active = true - if (navigator.vibrate) navigator.vibrate(15) - setDrag({ task, fromIdx, toIdx: fromIdx }) - moveGhost(st.current.startX, st.current.startY) - }, 400) - armListeners(ev) + startDrag(ev, task, false) }, // 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择) onContextMenu: (ev) => ev.preventDefault(), // eslint-disable-next-line react-hooks/exhaustive-deps - }), [ordered, onDrop]) + }), []) - /** 绑定到行尾拖动把手: 把手需 touch-action: none, 按下立即拖动。 - * pan-y 下整页可滚时浏览器滚动接管会取消指针流 (Android WebView 必现), - * 把手路径浏览器完全不接管, 是可靠入口。 */ + /** 绑定到行首拖动把手: 把手需 touch-action: none, 按下立即拖动 */ const bindHandle = useCallback((task) => ({ onPointerDown: (ev) => { if (ev.button !== undefined && ev.button !== 0) return - const fromIdx = ordered.findIndex((c) => c.id === task.columnId) - if (fromIdx < 0) return ev.stopPropagation() // 不触发行自身的长按路径 - st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, active: false } - st.current.active = true - if (navigator.vibrate) navigator.vibrate(15) - setDrag({ task, fromIdx, toIdx: fromIdx }) - moveGhost(st.current.startX, st.current.startY) - armListeners(ev, true) + startDrag(ev, task, true) }, onContextMenu: (ev) => ev.preventDefault(), // eslint-disable-next-line react-hooks/exhaustive-deps - }), [ordered, onDrop]) + }), []) - return { drag, bind, bindHandle, ghostRef, cancel: cleanup, justEnded } + return { drag, over, bind, bindHandle, cancel: cleanup, justEnded } } diff --git a/src/pages/checkin/MergedView.jsx b/src/pages/checkin/MergedView.jsx index 4de89c5..9424394 100644 --- a/src/pages/checkin/MergedView.jsx +++ b/src/pages/checkin/MergedView.jsx @@ -5,7 +5,6 @@ // - 撤销 / 编辑 / 归档 收进「⋯」底部面板 // - 未来日期只读, 按钮灰显「计划」 import { useCallback, useEffect, useMemo, useState } from 'react' -import { createPortal } from 'react-dom' import { Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast, } from 'antd-mobile' @@ -327,6 +326,7 @@ export default function MergedView({ date, onDateChange }) { } {...drag.bind(item)} > + ⠿ )} - ⠿
setSheetItem(item)}>⋯
@@ -364,16 +363,6 @@ export default function MergedView({ date, onDateChange }) { ))}
- {/* 跟手浮层: Portal 挂 body —— 页签滑动容器的 transform 会破坏 - position:fixed 的包含块, 浮层直接渲染在卡片流里会被压在下面 */} - {drag.dragItem && createPortal( -
- - {drag.dragItem.name} -
, - document.body, - )} - {/* 「⋯」操作面板: 撤销 / 编辑 / 归档 */} { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }} onToggleSub={onToggleSub} onToggle={onToggle} diff --git a/src/pages/task/ListView.jsx b/src/pages/task/ListView.jsx index f1e8b55..932516c 100644 --- a/src/pages/task/ListView.jsx +++ b/src/pages/task/ListView.jsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { createPortal } from 'react-dom' import TaskRow from '../../components/task/TaskRow.jsx' import useRowDrag from '../../components/task/useRowDrag.js' @@ -8,10 +7,10 @@ import useRowDrag from '../../components/task/useRowDrag.js' // 换列: 行/把手拖动, 跟手浮层, 手指划到哪个分组松手就落到哪 (与看板一致) // (列表只展示有任务的列, 空列需到看板视图拖入) export default function ListView({ - tasks, columns, onToggle, onOpen, onMore, onMoveColumn, + tasks, columns, onToggle, onOpen, onMore, onMoveColumn, onReorder, }) { const [collapsed, setCollapsed] = useState({}) - const drag = useRowDrag({ columns, onDrop: onMoveColumn }) + const drag = useRowDrag({ columns, onDrop: onMoveColumn, onReorder }) if (!tasks || tasks.length === 0) { return ( @@ -56,6 +55,7 @@ export default function ListView({ bindDrag={drag.bind} bindDragHandle={drag.bindHandle} dragTarget={dragging ? orderedCols[dragging.toIdx] : null} + dragOver={drag.over?.id === t.id ? drag.over : null} /> {kids.length > 0 && ( <> @@ -75,17 +75,6 @@ export default function ListView({ })}
))} - - {/* 跟手浮层: Portal 挂 body, 任务名 + 手指所在分组 */} - {drag.drag && createPortal( -
- {drag.drag.task.title} - {orderedCols[drag.drag.toIdx] && ( - → {orderedCols[drag.drag.toIdx].name} - )} -
, - document.body, - )}
) } diff --git a/src/pages/task/ProjectView.jsx b/src/pages/task/ProjectView.jsx index 829ac42..679371f 100644 --- a/src/pages/task/ProjectView.jsx +++ b/src/pages/task/ProjectView.jsx @@ -102,6 +102,28 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit } catch { /* 已回滚并提示 */ } } + /** 组内拖动排序: 按分组顺序展平重写 sortOrder (只动顶层任务) */ + const reorderTask = async (task, targetId, after) => { + const snapshot = tasks + 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 g = groups.find((l) => l.some((t) => t.id === task.id)) + if (!g) return + const rest = g.filter((t) => t.id !== task.id) + const ti = rest.findIndex((t) => t.id === targetId) + if (ti < 0) return + rest.splice(after ? ti + 1 : ti, 0, task) + const flat = groups.map((l) => (l === g ? rest : l)).flat() + const orderOf = new Map(flat.map((t, i) => [t.id, i])) + setTasks((prev) => (prev || []).map((t) => (orderOf.has(t.id) ? { ...t, sortOrder: orderOf.get(t.id) } : t))) + try { + await taskApi.reorder(projectId, flat.map((t) => t.id)) + } catch (e) { + setTasks(snapshot) + Toast.show({ content: e.message || '排序保存失败' }) + } + } + /** 改状态 (T-BD-14~16) */ const changeStatus = async (task, status) => { try { @@ -222,6 +244,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit onOpen={(t) => setOpenTask(t)} onMore={openTaskMenu} onMoveColumn={moveToColumn} + onReorder={reorderTask} /> ) : ( * { + margin: 0 !important; pointer-events: none; + opacity: .82; border-radius: 9px; + box-shadow: 0 12px 28px rgba(15,23,42,.28); +} /* ----- 日历视图 ----- */ .calendar-view { padding: 0 12px 20px; } @@ -1221,13 +1219,14 @@ textarea.inp { resize: vertical; } .task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; } .bcard, .task-row { touch-action: pan-y; } .bcard.dragging { - opacity: .5; transform: scale(.97); position: relative; z-index: 6; - box-shadow: 0 10px 22px rgba(15,23,42,.22); + opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px; } .task-row.dragging { - opacity: .55; transform: scale(.985); position: relative; z-index: 5; - box-shadow: 0 8px 18px rgba(15,23,42,.16); + opacity: .35; outline: 2px dashed var(--color-primary); outline-offset: 1px; } +.task-row.dragover { outline: 2px solid var(--color-primary); outline-offset: 1px; } +.task-row.dragover.after { border-bottom: 3px solid var(--color-primary); } +.task-row.dragover.before { border-top: 3px solid var(--color-primary); } .drag-pill { font-size: 11px; font-weight: 600; color: #fff; background: var(--color-primary); border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;