import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { Dialog, DotLoading, Input, Toast } from 'antd-mobile' import PageHeader from '../../components/PageHeader.jsx' import { projectApi } from '../../api/task.js' import { getLastProjectId, setLastProjectId, setRecentIds, prunePrefs, getRecentIds, getRecentOrder, setRecentOrder, } from './prefs.js' import RecentProjects from '../../components/task/RecentProjects.jsx' import ProjectView from './ProjectView.jsx' // 任务模块容器 (T-PM-06, T-VW-08/09) // /task 首屏: 上 = 最近项目条(3 个 + 更多入口), 下 = 当前项目的任务管理 // /task/list 完整项目列表(从「更多项目」进入; 支持拖动排序 + 新建) // /task/:pid 打开指定项目(深链), 布局同首屏 export default function TaskPage() { const { pid } = useParams() const { pathname } = useLocation() const navigate = useNavigate() const isList = pathname === '/task/list' const [projects, setProjects] = useState(null) const [err, setErr] = useState('') const [currentId, setCurrentId] = useState(null) const [createSignal, setCreateSignal] = useState(0) // 通知项目列表弹起新建对话框 const load = useCallback(() => { setErr('') projectApi.list() .then((d) => { const items = d.items || [] setProjects(items) prunePrefs(items.map((p) => p.id)) }) .catch((e) => setErr(e.message)) }, []) useEffect(() => { load() }, [load]) // 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09) useEffect(() => { if (!projects) return const ok = (x) => x && projects.some((p) => p.id === x && !p.archived) let id = pid ? Number(pid) : getLastProjectId() if (!ok(id)) id = projects.find((p) => p.isPersonal)?.id ?? projects[0]?.id ?? null if (!ok(id)) id = null setCurrentId(id) if (id) { setLastProjectId(id); setRecentIds(id) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [projects, pid]) const selectProject = (id) => { setCurrentId(id) setLastProjectId(id) setRecentIds(id) if (pid) navigate('/task', { replace: true }) } // 项目被删除 / 退出 / 归档后: 重载并回落个人默认项目 const handleExited = () => { setCurrentId(null) load() } if (isList) { return (
navigate('/task')}>‹ 我的项目
setCreateSignal((v) => v + 1)}>+ 新建
{ selectProject(id); navigate('/task') }} onChanged={load} createSignal={createSignal} onCreateConsumed={() => setCreateSignal(0)} />
) } return (
{err && !projects ? (
!

{err}

重试
) : !projects ? (
··

加载中…

) : ( <> navigate('/task/list')} /> {currentId != null && ( )} )}
) } const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e'] const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length] const LONG_PRESS = 300 const MOVE_TOLERANCE = 8 // 完整项目列表 (从「更多项目」进入) // 顺序: 手动排序(recentOrder, 拖动结果)优先 > 最近访问 > 列表序; 已归档固定在底部 function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal, onCreateConsumed }) { const [creating, setCreating] = useState(false) const [name, setName] = useState('') const [saving, setSaving] = useState(false) const [ver, setVer] = useState(0) // 拖动排序后强制重算 const [dragId, setDragId] = useState(null) // 拖拽中的项目 id (视觉态) const listRef = useRef(null) const timer = useRef(null) const startRef = useRef(null) const activeRef = useRef(false) const dragIdRef = useRef(null) // 头部「+ 新建」→ 弹起新建对话框 (一次性: 消费后清零, 否则下次进入该页会再次弹出) useEffect(() => { if (createSignal) { setCreating(true) onCreateConsumed?.() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [createSignal]) const active = useMemo(() => { const manual = getRecentOrder() const score = new Map(manual.map((id, i) => [id, i])) let n = score.size getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) }) const fb = n + 1000 return projects .filter((p) => !p.archived) .slice() .sort((a, b) => (score.get(a.id) ?? fb) - (score.get(b.id) ?? fb)) // eslint-disable-next-line react-hooks/exhaustive-deps }, [projects, ver]) if (err) { return (
!

{err}

重试
) } if (!projects) { return
··

加载中…

} const archived = projects.filter((p) => p.archived) async function create() { const t = name.trim() if (!t) { Toast.show({ content: '请输入项目名称' }); return } setSaving(true) try { const r = await projectApi.create({ name: t }) setName('') setCreating(false) Toast.show({ content: '已创建' }) onChanged?.() onOpen?.(r.item.id) } catch (e) { Toast.show({ content: e.message }) } finally { setSaving(false) } } // ---- 长按拖动排序 (纵向): 复用首屏卡片同一套 prefs.recentOrder ---- function cleanup() { if (timer.current) { clearTimeout(timer.current); timer.current = null } activeRef.current = false startRef.current = null dragIdRef.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 } } function indexAt(y) { const items = Array.from(listRef.current?.querySelectorAll('[data-pid]') || []) for (let i = 0; i < items.length; i++) { const r = items[i].getBoundingClientRect() if (y < r.top + r.height / 2) return i } return Math.max(0, items.length - 1) } function onMove(ev) { const p = pointOf(ev) if (!activeRef.current) { if (startRef.current && (Math.abs(p.x - startRef.current.x) > MOVE_TOLERANCE || Math.abs(p.y - startRef.current.y) > MOVE_TOLERANCE)) { if (timer.current) { clearTimeout(timer.current); timer.current = null } } return } if (ev.cancelable) ev.preventDefault() const ids = active.map((x) => x.id) const from = ids.indexOf(dragIdRef.current) if (from < 0) return const to = indexAt(p.y) if (to !== from) { const [moved] = ids.splice(from, 1) ids.splice(to, 0, moved) setRecentOrder(ids) setVer((v) => v + 1) } } function onEnd() { cleanup() setDragId(null) } function beginPress(p, ev) { if (ev.button && ev.button !== 0) return const pt = pointOf(ev) startRef.current = pt activeRef.current = false cleanup() dragIdRef.current = p.id timer.current = setTimeout(() => { activeRef.current = true setDragId(p.id) 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) } useEffect(() => () => cleanup(), []) return (
长按项目可拖动排序(本机记忆,与首屏最近项目一致)
{active.map((p) => (
beginPress(p, ev)} onMouseDown={(ev) => beginPress(p, ev)} onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }} >
{(p.name || '?').slice(0, 1)}
{p.name} {p.isPersonal && 个人}
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
⠿
))} {active.length === 0 && (
▤

还没有协作项目

setCreating(true)}>新建项目
)} {archived.length > 0 && ( <>
已归档({archived.length})
{archived.map((p) => (
onOpen(p.id)}>
{(p.name || '?').slice(0, 1)}
{p.name}
已归档 · {p.taskCount ?? 0} 个任务
›
))} )}
} actions={[ { key: 'cancel', text: '取消', onClick: () => setCreating(false) }, { key: 'ok', text: saving ? '创建中…' : '创建', bold: true, onClick: create }, ]} onClose={() => setCreating(false)} />
) }