Files
iboard/src/components/task/RecentProjects.jsx
T
jif2.zhang 0cdbfb434f
Docker Build and Push / build-image (push) Successful in 4m8s
feat(V0.2): 任务模块界面调整 —— 首屏/项目管理/底部安全距离
按 2026-10-24 评审的 5 条调整实现 (需求 T-VW-08~10 / T-PJ-11/12 / T-UI-11):

首屏 (T-VW-08/09):
- /task 改为「上=最近项目条(3 个+更多入口), 下=当前项目任务管理」
- 完整项目列表迁至 /task/list, 从「更多项目」进入; /task/:pid 保留供深链

最近项目排序 (T-VW-10):
- 卡片长按 300ms 拖动换位, 结果存设备端 prefs.recentOrder
- 顺序 = 手动排序 > 最近访问(recentIds) > 列表序, 归档项目不展示

项目管理 (T-PJ-11/12):
- 新增 ProjectManage: 成员 / 看板列 / 项目信息 三分区统一入口
- 项目信息含 改名 / 归档切换 / 删除(个人项目隐藏) / 退出(仅创建人)
- 项目页头部只留「项目管理 + 新增」, 移除散置的成员/列设置按钮

底部安全距离 (T-UI-11):
- 任务详情 / 人员选择 / 拷贝弹层 padding-bottom = 安全区 + 20px
- 小屏下弹层 maxHeight + 内部滚动, 底部按钮完整可见

已验证: 97 文件语法 0 错误、相对导入 0 缺失
2026-10-24 20:32:44 +08:00

137 lines
4.9 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 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 (
<>
<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={{ background: colorOf(p.id) }}>
{(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>
</>
)
}