diff --git a/src/components/task/RecentProjects.jsx b/src/components/task/RecentProjects.jsx index 3c86bf6..93551b0 100644 --- a/src/components/task/RecentProjects.jsx +++ b/src/components/task/RecentProjects.jsx @@ -12,7 +12,8 @@ const MOVE_TOLERANCE = 8 export default function RecentProjects({ projects, currentId, onSelect, onMore }) { const [ver, setVer] = useState(0) // 手动排序后强制重算 - const [dragIdx, setDragIdx] = useState(-1) // 拖拽中的卡片下标 + const [dragIdx, setDragIdx] = useState(-1) // 拖拽中的卡片下标 (视觉态) + const dragIdxRef = useRef(-1) // 监听器闭包读的下标 (state 在闭包里是旧值) const barRef = useRef(null) const timer = useRef(null) const start = useRef(null) @@ -51,9 +52,9 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore } } // 按横坐标计算落点下标 (卡片宽 96 + 间距 8) - function indexAt(x) { + function indexAt(x, fallback = -1) { const rect = barRef.current?.getBoundingClientRect() - if (!rect) return dragIdx + if (!rect) return fallback const rel = x - rect.left + 4 return Math.max(0, Math.min(shown.length - 1, Math.floor(rel / 104))) } @@ -68,13 +69,16 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore } return } if (ev.cancelable) ev.preventDefault() - const to = indexAt(p.x) - if (to !== dragIdx && to >= 0) { + // 用 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(dragIdx, 1) + const [moved] = ids.splice(from, 1) ids.splice(to, 0, moved) setRecentOrder(ids) + dragIdxRef.current = to setDragIdx(to) setVer((v) => v + 1) } @@ -83,6 +87,7 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore } function onEnd() { if (active.current) setVer((v) => v + 1) cleanup() + dragIdxRef.current = -1 setDragIdx(-1) } @@ -92,6 +97,7 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore } start.current = p active.current = false cleanup() + dragIdxRef.current = i timer.current = setTimeout(() => { active.current = true setDragIdx(i) diff --git a/src/hooks/useKeyboardInset.js b/src/hooks/useKeyboardInset.js new file mode 100644 index 0000000..096bc21 --- /dev/null +++ b/src/hooks/useKeyboardInset.js @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react' + +// 键盘弹出时的"插入量": window.innerHeight 与 visualViewport.height 的差 +// Android WebView 键盘弹出时 visualViewport 变小; 用它把底部弹层的内容抬到键盘上方, +// 避免输入框(如任务详情里的"添加子任务")被键盘遮挡。 +// 阈值 90px 过滤掉地址栏收起之类的轻微抖动; 无 visualViewport 时恒为 0 (iOS 原生键盘通常整体缩放, 无需处理)。 +export default function useKeyboardInset() { + const [inset, setInset] = useState(0) + + useEffect(() => { + const vv = window.visualViewport + if (!vv) return + const onChange = () => { + const diff = window.innerHeight - vv.height - vv.offsetTop + setInset(diff > 90 ? Math.round(diff) : 0) + } + vv.addEventListener('resize', onChange) + vv.addEventListener('scroll', onChange) + onChange() + return () => { + vv.removeEventListener('resize', onChange) + vv.removeEventListener('scroll', onChange) + } + }, []) + + return inset +} diff --git a/src/hooks/useOverlayBack.js b/src/hooks/useOverlayBack.js new file mode 100644 index 0000000..0f87f11 --- /dev/null +++ b/src/hooks/useOverlayBack.js @@ -0,0 +1,24 @@ +import { useEffect } from 'react' + +// 弹层 / 全屏覆盖层打开时拦截返回键: 先关弹层, 而不是离开当前路由 (bug: 返回跑到上一个 Tab) +// 原理: 打开时 pushState 一条哨兵记录 (URL 不变), 返回键先消费它; +// popstate => 视为"关闭弹层"。正常关闭 (点 X / 遮罩) 时在 cleanup 里 history.back() 撤掉哨兵。 +// 注意: close 必须把 open 置 false, 否则 cleanup 不会执行、哨兵残留。 +export default function useOverlayBack(open, close) { + useEffect(() => { + if (!open) return + let closedByBack = false + window.history.pushState({ __overlay: true }, '') + const onPop = () => { + closedByBack = true + close() + } + window.addEventListener('popstate', onPop) + return () => { + window.removeEventListener('popstate', onPop) + // 正常关闭: 撤销哨兵 (不产生可见的后退, 因为 URL 未变) + if (!closedByBack) window.history.back() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) +} diff --git a/src/pages/task/ProjectManage.jsx b/src/pages/task/ProjectManage.jsx index ce3a219..e0f292c 100644 --- a/src/pages/task/ProjectManage.jsx +++ b/src/pages/task/ProjectManage.jsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { Dialog, Input, Toast } from 'antd-mobile' import { projectApi } from '../../api/task.js' import { useAuth } from '../../auth/AuthContext.jsx' +import useOverlayBack from '../../hooks/useOverlayBack.js' import ColumnSettings from './ColumnSettings.jsx' import ProjectMembers from './ProjectMembers.jsx' @@ -17,6 +18,9 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) const isOwner = user?.id === project.ownerId const isPersonal = !!project.isPersonal + // 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理 + useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() }) + if (view === 'columns') { return ( m.userId === form.assigneeId)?.name const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name) @@ -133,7 +140,14 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o visible onMaskClick={onClose} position="bottom" - bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }} + bodyStyle={{ + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + maxHeight: `calc(88vh - ${kb}px)`, + overflowY: 'auto', + // 键盘弹出时抬升内容 (kb), 否则"添加子任务"输入框被键盘遮挡 + padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`, + }} >
{task.title}
@@ -223,6 +237,10 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o maxLength={200} onChange={(e) => setNewSub(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') addSub() }} + onFocus={(e) => { + // 键盘弹出动画 (~300ms) 后把输入框滚到可视区中部, 双保险防遮挡 + setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) + }} />
diff --git a/src/pages/task/TaskPage.jsx b/src/pages/task/TaskPage.jsx index 50c2097..be05a40 100644 --- a/src/pages/task/TaskPage.jsx +++ b/src/pages/task/TaskPage.jsx @@ -1,17 +1,18 @@ -import { useCallback, useEffect, useState } from 'react' +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/list 完整项目列表(从「更多项目」进入; 支持拖动排序 + 新建) // /task/:pid 打开指定项目(深链), 布局同首屏 export default function TaskPage() { const { pid } = useParams() @@ -21,6 +22,7 @@ export default function TaskPage() { const [projects, setProjects] = useState(null) const [err, setErr] = useState('') const [currentId, setCurrentId] = useState(null) + const [createSignal, setCreateSignal] = useState(0) // 通知项目列表弹起新建对话框 const load = useCallback(() => { setErr('') @@ -66,6 +68,9 @@ export default function TaskPage() {
navigate('/task')}>‹ 我的项目 +
+ setCreateSignal((v) => v + 1)}>+ 新建 +
{ selectProject(id); navigate('/task') }} onChanged={load} + createSignal={createSignal} />
@@ -119,11 +125,40 @@ export default function TaskPage() { ) } +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 + // 完整项目列表 (从「更多项目」进入) -function ProjectList({ projects, err, onRetry, onOpen, onChanged }) { +// 顺序: 手动排序(recentOrder, 拖动结果)优先 > 最近访问 > 列表序; 已归档固定在底部 +function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal }) { 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) }, [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 ( @@ -138,7 +173,6 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) { return
··

加载中…

} - const active = projects.filter((p) => !p.archived) const archived = projects.filter((p) => p.archived) async function create() { @@ -159,14 +193,92 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) { } } - const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e'] - const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length] + // ---- 长按拖动排序 (纵向): 复用首屏卡片同一套 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) => ( -
onOpen(p.id)}> +
beginPress(p, ev)} + onMouseDown={(ev) => beginPress(p, ev)} + onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }} + >
{(p.name || '?').slice(0, 1)}
@@ -179,7 +291,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) { {p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
-
›
+
⠿
))} @@ -223,6 +335,6 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) { ]} onClose={() => setCreating(false)} /> - +
) } diff --git a/src/styles/globals.css b/src/styles/globals.css index 2b3f3c4..c7f8b7a 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1019,7 +1019,8 @@ body.me-safe-top .app-safe-top { height: 0; } } /* ---- 列设置 / 成员页 ---- */ -.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; } +/* fixed 覆盖层要自己让出顶部状态栏 (复用 main.jsx 校准的 --safe-top), 否则与系统工具栏重合 */ +.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; padding-top: var(--safe-top); } .cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; } .cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; } .cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; } @@ -1130,4 +1131,7 @@ textarea.inp { resize: vertical; } .task-projbar .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; } .task-projbar .title { font-size: 15px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .task-projbar .right { margin-left: auto; display: flex; gap: 6px; flex-shrink: 0; } -.pv-embed .task-content { flex: 1 1 auto; } +.pv-embed .task-content { flex: 1 1 auto; } + +/* ---- 项目列表拖动排序 (bug#3) ---- */ +.proj-item.dragging { opacity: .65; transform: scale(.98); box-shadow: 0 8px 18px rgba(15,23,42,.16); z-index: 5; position: relative; }