Files
iboard/src/components/task/RecentProjects.jsx
T
jif2.zhang cee21a39d9
Docker Build and Push / build-image (push) Successful in 1m22s
feat(V0.2): 快速添加输入框三行/失焦收起; ⋯菜单点选即收; 项目图标可设图片
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭,
  焦点离开且无内容时自动收起
- 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单
- Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片,
  客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位
  显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
2026-10-25 12:02:29 +08:00

148 lines
5.4 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 dragIdxRef = useRef(-1) // 监听器闭包读的下标 (state 在闭包里是旧值)
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, fallback = -1) {
const rect = barRef.current?.getBoundingClientRect()
if (!rect) return fallback
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()
// 用 ref 取当前拖拽下标: 监听器闭包里的 state 是旧值
const from = dragIdxRef.current
const to = indexAt(p.x, from)
if (to !== from && to >= 0 && from >= 0) {
// 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生)
const ids = shown.map((p2) => p2.id)
const [moved] = ids.splice(from, 1)
ids.splice(to, 0, moved)
setRecentOrder(ids)
dragIdxRef.current = to
setDragIdx(to)
setVer((v) => v + 1)
}
}
function onEnd() {
if (active.current) setVer((v) => v + 1)
cleanup()
dragIdxRef.current = -1
setDragIdx(-1)
}
function beginPress(i, ev) {
if (ev.button && ev.button !== 0) return
const p = pointOf(ev)
start.current = p
active.current = false
cleanup()
dragIdxRef.current = i
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 (
<>
<div className="rp-bar" ref={barRef}>
{shown.map((p, i) => (
<div
key={p.id}
className={`rp-card${p.id === currentId ? ' on' : ''}${i === dragIdx ? ' dragging' : ''}`}
data-project-id={p.id}
onTouchStart={(ev) => beginPress(i, ev)}
onMouseDown={(ev) => beginPress(i, ev)}
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
>
<div
className="rp-ico"
style={p.icon
? { background: `url(${p.icon}) center/cover no-repeat` }
: { background: colorOf(p.id) }}
>
{p.icon ? '' : (p.name || '?').slice(0, 1)}
</div>
<div className="rp-name">{p.name}</div>
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
<div className="rp-grip">⠿</div>
</div>
))}
<div className="rp-more" onClick={onMore}>
<div className="rp-more-ico">▤</div>
更多项目
</div>
</div>
<div className="rp-drag-tip">长按卡片可调整顺序(本机记忆)</div>
</>
)
}