Docker Build and Push / build-image (push) Successful in 4m8s
按 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 缺失
137 lines
4.9 KiB
React
137 lines
4.9 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 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>
|
||
</>
|
||
)
|
||
}
|