fix(V0.2): 任务模块 4 个 bug —— 覆盖层安全区/返回键/项目列表排序/键盘遮挡
Docker Build and Push / build-image (push) Successful in 4m17s

1. 项目管理等 cs-page 覆盖层与系统状态栏重合:
   .cs-page 补 padding-top: var(--safe-top) (复用 main.jsx 实测校准的变量)

2. 项目管理按返回键跑到上一个 Tab:
   新增 useOverlayBack —— 覆盖层打开时 pushState 哨兵, 返回键先关覆盖层
   应用于 项目管理(内层视图先回主区) 与 任务详情弹层

3. 更多项目页:
   - 头部补「+ 新建」入口
   - 项目列表支持长按拖动排序(纵向), 顺序存设备端, 与首屏最近项目共用 recentOrder
   - 顺带修复首屏 RecentProjects 拖动排序的闭包缺陷(监听器读到旧下标, 首次移动
     会 splice(-1)); 两处均改为 ref 取当前下标

4. 任务详情输入子任务被键盘遮挡:
   新增 useKeyboardInset (visualViewport 差值), 键盘弹出时抬升弹层内容并收缩
   maxHeight; 输入框聚焦后再 scrollIntoView 到可视区中部 (双保险)

已验证: 99 文件语法 0 错误、相对导入 0 缺失
This commit is contained in:
jif2.zhang
2026-10-24 21:09:19 +08:00
parent 0cdbfb434f
commit 1fd7522fa6
7 changed files with 215 additions and 20 deletions
+12 -6
View File
@@ -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)
+27
View File
@@ -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
}
+24
View File
@@ -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])
}
+4
View File
@@ -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 (
<ColumnSettings
+19 -1
View File
@@ -5,6 +5,8 @@ import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
import UserPicker from '../../components/task/UserPicker.jsx'
import StatusDot from '../../components/task/StatusDot.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
@@ -23,6 +25,11 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡 (bug#4)
const kb = useKeyboardInset()
// 返回键: 先关详情弹层, 不离开任务页
useOverlayBack(true, onClose)
const assigneeName = members.find((m) => 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)`,
}}
>
<div className="sheet-title">{task.title}</div>
<div className="sheet-sub">
@@ -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)
}}
/>
</div>
+123 -11
View File
@@ -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() {
<div className="navbar-sub task-projbar">
<span className="back" onClick={() => navigate('/task')}>‹</span>
<span className="title">我的项目</span>
<div className="right">
<span className="mini-btn solid" onClick={() => setCreateSignal((v) => v + 1)}>+ 新建</span>
</div>
</div>
<div className="task-content">
<ProjectList
@@ -74,6 +79,7 @@ export default function TaskPage() {
onRetry={load}
onOpen={(id) => { selectProject(id); navigate('/task') }}
onChanged={load}
createSignal={createSignal}
/>
</div>
</div>
@@ -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 <div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
}
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 (
<>
<div style={{ height: 8 }} />
<div ref={listRef}>
<div className="rp-drag-tip" style={{ padding: '8px 14px 6px' }}>
长按项目可拖动排序(本机记忆,与首屏最近项目一致)
</div>
{active.map((p) => (
<div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}>
<div
className={`proj-item${p.id === dragId ? ' dragging' : ''}`}
key={p.id}
data-pid={p.id}
onTouchStart={(ev) => beginPress(p, ev)}
onMouseDown={(ev) => beginPress(p, ev)}
onClick={() => { if (!activeRef.current && !dragId) onOpen(p.id) }}
>
<div className="pico" style={{ background: colorOf(p) }}>
{(p.name || '?').slice(0, 1)}
</div>
@@ -179,7 +291,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
</div>
</div>
<div className="pcount">›</div>
<div className="pcount">⠿</div>
</div>
))}
@@ -223,6 +335,6 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
]}
onClose={() => setCreating(false)}
/>
</>
</div>
)
}
+6 -2
View File
@@ -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; }