fix(V0.2): 任务模块 4 个 bug —— 覆盖层安全区/返回键/项目列表排序/键盘遮挡
Docker Build and Push / build-image (push) Successful in 4m17s
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user