Docker Build and Push / build-image (push) Successful in 1m22s
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭, 焦点离开且无内容时自动收起 - 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单 - Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片, 客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位 显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
148 lines
5.4 KiB
React
148 lines
5.4 KiB
React
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>
|
||
</>
|
||
)
|
||
}
|