import { useEffect, useMemo, useRef, useState } from 'react' import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js' // 首屏最近项目条 (T-VW-08/10) // - 展示最近使用的 3 个项目 + 「更多项目」入口 // - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序 // - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端) const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e'] const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length] const LONG_PRESS = 300 const MOVE_TOLERANCE = 8 export default function RecentProjects({ projects, currentId, onSelect, onMore }) { const [ver, setVer] = useState(0) // 手动排序后强制重算 const [dragIdx, setDragIdx] = useState(-1) // 拖拽中的卡片下标 const barRef = useRef(null) const timer = useRef(null) const start = useRef(null) const active = useRef(false) // 展示顺序: 手动 > 自动 > 列表序, 只取未归档的前 3 const shown = useMemo(() => { if (!projects) return [] const score = new Map() getRecentOrder().forEach((id, i) => score.set(id, i)) let n = score.size getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) }) const fallback = n + 1000 return [...projects] .filter((p) => !p.archived) .sort((a, b) => (score.get(a.id) ?? fallback) - (score.get(b.id) ?? fallback)) .slice(0, 3) // eslint-disable-next-line react-hooks/exhaustive-deps }, [projects, ver]) useEffect(() => () => cleanup(), []) function cleanup() { if (timer.current) { clearTimeout(timer.current); timer.current = null } active.current = false start.current = null document.removeEventListener('touchmove', onMove, { passive: false }) document.removeEventListener('touchend', onEnd) document.removeEventListener('mousemove', onMove) document.removeEventListener('mouseup', onEnd) } const pointOf = (ev) => { const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev return { x: t.clientX, y: t.clientY } } // 按横坐标计算落点下标 (卡片宽 96 + 间距 8) function indexAt(x) { const rect = barRef.current?.getBoundingClientRect() if (!rect) return dragIdx const rel = x - rect.left + 4 return Math.max(0, Math.min(shown.length - 1, Math.floor(rel / 104))) } function onMove(ev) { const p = pointOf(ev) if (!active.current) { // 未激活前: 移动超容差则取消长按 (用户想滚动) if (start.current && (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 to = indexAt(p.x) if (to !== dragIdx && to >= 0) { // 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生) const ids = shown.map((p2) => p2.id) const [moved] = ids.splice(dragIdx, 1) ids.splice(to, 0, moved) setRecentOrder(ids) setDragIdx(to) setVer((v) => v + 1) } } function onEnd() { if (active.current) setVer((v) => v + 1) cleanup() setDragIdx(-1) } function beginPress(i, ev) { if (ev.button && ev.button !== 0) return const p = pointOf(ev) start.current = p active.current = false cleanup() timer.current = setTimeout(() => { active.current = true setDragIdx(i) 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) } if (!projects) return null return ( <>
{shown.map((p, i) => (
beginPress(i, ev)} onMouseDown={(ev) => beginPress(i, ev)} onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }} >
{(p.name || '?').slice(0, 1)}
{p.name}
{p.taskCount ?? 0} 个任务
⠿
))}
▤
更多项目
长按卡片可调整顺序(本机记忆)
) }