fix(V0.2): 二轮评审 6 项 —— BUILD_ID 格式 / 菜单与详情精简 / 拖动重做 / 键盘统一方案
Docker Build and Push / build-image (push) Successful in 4m5s
Docker Build and Push / build-image (push) Successful in 4m5s
1. BUILD_ID 改为 yyyyMMddHHmmss (去掉中间点), 仍 UTC+8
2. 任务 ⋯ 菜单去掉顶部任务名标题
3. 任务详情去掉底部「拷贝到其他项目」「删除任务」按钮 (⋯ 菜单已有)
4+6. 键盘遮挡统一方案 (重点设计 §7.2):
- index.html 加 interactive-widget=resizes-content
- App 级 useKeyboardInsetWatcher 写 --kb-inset (visualViewport+window 双源)
- 底部弹层统一 .kb-safe 类 (安全区+20px+键盘插入量, dvh 收缩+内部滚动)
- TaskDetail/UserPicker/CopyTaskPopup/信息块弹层全部迁移, 删除 per-component hook
- 信息块 textarea 补 focus scrollIntoView 兜底
5. 拖动方案重做 (重点设计 §7.1):
- 新 useColumnDrag: Pointer Events + setPointerCapture, 长按400ms原位抬起,
elementFromPoint 高亮目标列, 边缘 rAF 自动横滚; 移除手指跟随浮层(任务名)
- 列表行长按 500ms → 「移动到列」ActionSheet (不做自由拖动)
- 移除「拖到卡片转子任务」误触路径, 统一走详情页层级区
- 删除旧 useLongPressDrag
已验证: 99 文件语法 0 错误、导入 0 缺失
This commit is contained in:
@@ -38,10 +38,7 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||
}}
|
||||
className="kb-safe"
|
||||
>
|
||||
<div className="sheet-title">跨项目拷贝</div>
|
||||
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import StatusDot from './StatusDot.jsx'
|
||||
|
||||
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
||||
// 长按 500ms → onLongPress (移动到列菜单); 点击其他区域 → onClick (编辑)
|
||||
const LONG_PRESS = 500
|
||||
|
||||
export default function TaskRow({
|
||||
task, onToggle, onClick, onMore, indent = false, children,
|
||||
task, onToggle, onClick, onMore, onLongPress, indent = false, children,
|
||||
}) {
|
||||
const timer = useRef(null)
|
||||
const moved = useRef(false)
|
||||
|
||||
const clear = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null } }
|
||||
useEffect(() => () => clear(), [])
|
||||
|
||||
const startPress = () => {
|
||||
moved.current = false
|
||||
clear()
|
||||
if (!onLongPress) return
|
||||
timer.current = setTimeout(() => {
|
||||
if (!moved.current) {
|
||||
if (navigator.vibrate) navigator.vibrate(15)
|
||||
onLongPress(task)
|
||||
}
|
||||
}, LONG_PRESS)
|
||||
}
|
||||
const cancelPress = () => clear()
|
||||
const markMoved = () => { moved.current = true; clear() }
|
||||
|
||||
const done = task.status === 'done'
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
||||
data-task-id={indent ? undefined : task.id}
|
||||
onClick={onClick}
|
||||
onClick={() => { if (!moved.current) onClick?.(task) }}
|
||||
onTouchStart={startPress}
|
||||
onTouchMove={markMoved}
|
||||
onTouchEnd={cancelPress}
|
||||
onMouseDown={startPress}
|
||||
onMouseUp={cancelPress}
|
||||
>
|
||||
<div
|
||||
className="box"
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function UserPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
|
||||
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe">
|
||||
<div className="sheet-title">{title}</div>
|
||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做)
|
||||
// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现):
|
||||
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
||||
// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消)
|
||||
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证
|
||||
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
||||
// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列
|
||||
// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交;
|
||||
// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」
|
||||
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 stopAutoScroll = () => {
|
||||
st.current.autoScroll = 0
|
||||
if (st.current.raf) { cancelAnimationFrame(st.current.raf); st.current.raf = 0 }
|
||||
}
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
|
||||
st.current.active = false
|
||||
stopAutoScroll()
|
||||
setDragTask(null)
|
||||
setOverCol(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => cleanup(), [cleanup])
|
||||
|
||||
const hitColumn = (x, y) => {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
const colEl = el?.closest?.('[data-column-id]')
|
||||
return colEl ? Number(colEl.dataset.columnId) : null
|
||||
}
|
||||
|
||||
// 边缘自动滚动 (±36px 触发, 每帧 10px)
|
||||
const autoScroll = (container, clientX) => {
|
||||
if (!container) { stopAutoScroll(); return }
|
||||
const r = container.getBoundingClientRect()
|
||||
const edge = 36
|
||||
let dir = 0
|
||||
if (clientX < r.left + edge) dir = -1
|
||||
else if (clientX > r.right - edge) dir = 1
|
||||
st.current.autoScroll = dir
|
||||
if (!dir) { stopAutoScroll(); return }
|
||||
if (st.current.raf) return
|
||||
const step = () => {
|
||||
if (!st.current.autoScroll) return
|
||||
container.scrollLeft += st.current.autoScroll * 10
|
||||
st.current.raf = requestAnimationFrame(step)
|
||||
}
|
||||
st.current.raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onPointerDown = (task) => (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)
|
||||
}
|
||||
|
||||
const onPointerMove = (ev) => {
|
||||
const s = st.current
|
||||
if (!s.active) {
|
||||
if (s.timer && (Math.abs(ev.clientX - s.startX) > 10 || Math.abs(ev.clientY - s.startY) > 10)) {
|
||||
clearTimeout(s.timer); s.timer = null
|
||||
}
|
||||
return
|
||||
}
|
||||
ev.preventDefault()
|
||||
const col = hitColumn(ev.clientX, ev.clientY)
|
||||
setOverCol(col)
|
||||
autoScroll(document.querySelector(scrollSelector), ev.clientX)
|
||||
}
|
||||
|
||||
const onPointerUp = (ev) => {
|
||||
const s = st.current
|
||||
if (s.active) {
|
||||
const col = hitColumn(ev.clientX, ev.clientY)
|
||||
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col)
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
||||
const bind = useCallback((task) => ({
|
||||
onPointerDown: (ev) => {
|
||||
// 捕获前先让事件正常冒泡(点击等); 只做长按计时
|
||||
onPointerDown(task)(ev)
|
||||
// 捕获: 后续 move/up 都派发给该元素, 不丢
|
||||
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
||||
const el = ev.currentTarget
|
||||
const move = (e2) => onPointerMove(e2)
|
||||
const up = (e2) => {
|
||||
onPointerUp(e2)
|
||||
el.removeEventListener('pointermove', move)
|
||||
el.removeEventListener('pointerup', up)
|
||||
el.removeEventListener('pointercancel', up)
|
||||
}
|
||||
el.addEventListener('pointermove', move)
|
||||
el.addEventListener('pointerup', up)
|
||||
el.addEventListener('pointercancel', up)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}), [])
|
||||
|
||||
return { dragTask, overCol, bind, cancel: cleanup }
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
// 长按拖拽 (T-UI-07, T-ST-12)
|
||||
// 用法: const drag = useLongPressDrag({ onDrop })
|
||||
// - 长按 300ms 后进入拖拽态, 产生浮层数据
|
||||
// - 松手时用 elementFromPoint 找落点: [data-column-id] / [data-task-id]
|
||||
// - 返回 { dragging, ghost, bind(task) }
|
||||
const LONG_PRESS = 300
|
||||
const MOVE_TOLERANCE = 8
|
||||
|
||||
export default function useLongPressDrag({ onDropColumn, onDropTask, onReject } = {}) {
|
||||
const [dragging, setDragging] = useState(null) // { task, x, y }
|
||||
const timer = useRef(null)
|
||||
const start = useRef(null)
|
||||
const active = useRef(false)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||
start.current = null
|
||||
active.current = false
|
||||
document.removeEventListener('touchmove', onMove, { passive: false })
|
||||
document.removeEventListener('touchend', onEnd)
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onEnd)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const pointOf = (ev) => {
|
||||
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
|
||||
return { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
function onMove(ev) {
|
||||
if (!active.current) {
|
||||
// 未进入拖拽前, 若移动超过容差则取消长按(用户想滚动)
|
||||
if (start.current) {
|
||||
const p = pointOf(ev)
|
||||
if (Math.abs(p.x - start.current.x) > MOVE_TOLERANCE || Math.abs(p.y - start.current.y) > MOVE_TOLERANCE) {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev.cancelable) ev.preventDefault()
|
||||
const p = pointOf(ev)
|
||||
setDragging((d) => (d ? { ...d, x: p.x, y: p.y } : d))
|
||||
}
|
||||
|
||||
function onEnd(ev) {
|
||||
if (!active.current) { cleanup(); return }
|
||||
const p = pointOf(ev)
|
||||
const el = document.elementFromPoint(p.x, p.y)
|
||||
const taskEl = el?.closest?.('[data-task-id]')
|
||||
const colEl = el?.closest?.('[data-column-id]')
|
||||
const task = dragging?.task
|
||||
cleanup()
|
||||
setDragging(null)
|
||||
if (!task) return
|
||||
|
||||
if (taskEl) {
|
||||
const targetId = Number(taskEl.dataset.taskId)
|
||||
if (targetId && targetId !== task.id) onDropTask?.(task, targetId)
|
||||
return
|
||||
}
|
||||
if (colEl) {
|
||||
const columnId = Number(colEl.dataset.columnId)
|
||||
if (columnId && columnId !== task.columnId) onDropColumn?.(task, columnId)
|
||||
return
|
||||
}
|
||||
onReject?.('未放到有效位置')
|
||||
}
|
||||
|
||||
const bind = useCallback((task) => {
|
||||
const onStart = (ev) => {
|
||||
if (ev.button && ev.button !== 0) return
|
||||
const p = pointOf(ev)
|
||||
start.current = p
|
||||
setDragging(null)
|
||||
active.current = false
|
||||
timer.current = setTimeout(() => {
|
||||
active.current = true
|
||||
setDragging({ task, x: p.x, y: p.y })
|
||||
if (navigator.vibrate) navigator.vibrate(10)
|
||||
}, LONG_PRESS)
|
||||
document.addEventListener('touchmove', onMove, { passive: false })
|
||||
document.addEventListener('touchend', onEnd)
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onEnd)
|
||||
}
|
||||
return {
|
||||
onTouchStart: onStart,
|
||||
onMouseDown: onStart,
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return { dragging, bind, cancel: () => { cleanup(); setDragging(null) } }
|
||||
}
|
||||
Reference in New Issue
Block a user