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:
@@ -12,7 +12,8 @@ const MOVE_TOLERANCE = 8
|
|||||||
|
|
||||||
export default function RecentProjects({ projects, currentId, onSelect, onMore }) {
|
export default function RecentProjects({ projects, currentId, onSelect, onMore }) {
|
||||||
const [ver, setVer] = useState(0) // 手动排序后强制重算
|
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 barRef = useRef(null)
|
||||||
const timer = useRef(null)
|
const timer = useRef(null)
|
||||||
const start = useRef(null)
|
const start = useRef(null)
|
||||||
@@ -51,9 +52,9 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore }
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 按横坐标计算落点下标 (卡片宽 96 + 间距 8)
|
// 按横坐标计算落点下标 (卡片宽 96 + 间距 8)
|
||||||
function indexAt(x) {
|
function indexAt(x, fallback = -1) {
|
||||||
const rect = barRef.current?.getBoundingClientRect()
|
const rect = barRef.current?.getBoundingClientRect()
|
||||||
if (!rect) return dragIdx
|
if (!rect) return fallback
|
||||||
const rel = x - rect.left + 4
|
const rel = x - rect.left + 4
|
||||||
return Math.max(0, Math.min(shown.length - 1, Math.floor(rel / 104)))
|
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
|
return
|
||||||
}
|
}
|
||||||
if (ev.cancelable) ev.preventDefault()
|
if (ev.cancelable) ev.preventDefault()
|
||||||
const to = indexAt(p.x)
|
// 用 ref 取当前拖拽下标: 监听器闭包里的 state 是旧值
|
||||||
if (to !== dragIdx && to >= 0) {
|
const from = dragIdxRef.current
|
||||||
|
const to = indexAt(p.x, from)
|
||||||
|
if (to !== from && to >= 0 && from >= 0) {
|
||||||
// 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生)
|
// 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生)
|
||||||
const ids = shown.map((p2) => p2.id)
|
const ids = shown.map((p2) => p2.id)
|
||||||
const [moved] = ids.splice(dragIdx, 1)
|
const [moved] = ids.splice(from, 1)
|
||||||
ids.splice(to, 0, moved)
|
ids.splice(to, 0, moved)
|
||||||
setRecentOrder(ids)
|
setRecentOrder(ids)
|
||||||
|
dragIdxRef.current = to
|
||||||
setDragIdx(to)
|
setDragIdx(to)
|
||||||
setVer((v) => v + 1)
|
setVer((v) => v + 1)
|
||||||
}
|
}
|
||||||
@@ -83,6 +87,7 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore }
|
|||||||
function onEnd() {
|
function onEnd() {
|
||||||
if (active.current) setVer((v) => v + 1)
|
if (active.current) setVer((v) => v + 1)
|
||||||
cleanup()
|
cleanup()
|
||||||
|
dragIdxRef.current = -1
|
||||||
setDragIdx(-1)
|
setDragIdx(-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +97,7 @@ export default function RecentProjects({ projects, currentId, onSelect, onMore }
|
|||||||
start.current = p
|
start.current = p
|
||||||
active.current = false
|
active.current = false
|
||||||
cleanup()
|
cleanup()
|
||||||
|
dragIdxRef.current = i
|
||||||
timer.current = setTimeout(() => {
|
timer.current = setTimeout(() => {
|
||||||
active.current = true
|
active.current = true
|
||||||
setDragIdx(i)
|
setDragIdx(i)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Dialog, Input, Toast } from 'antd-mobile'
|
import { Dialog, Input, Toast } from 'antd-mobile'
|
||||||
import { projectApi } from '../../api/task.js'
|
import { projectApi } from '../../api/task.js'
|
||||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||||
|
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||||
import ColumnSettings from './ColumnSettings.jsx'
|
import ColumnSettings from './ColumnSettings.jsx'
|
||||||
import ProjectMembers from './ProjectMembers.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 isOwner = user?.id === project.ownerId
|
||||||
const isPersonal = !!project.isPersonal
|
const isPersonal = !!project.isPersonal
|
||||||
|
|
||||||
|
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
|
||||||
|
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
|
||||||
|
|
||||||
if (view === 'columns') {
|
if (view === 'columns') {
|
||||||
return (
|
return (
|
||||||
<ColumnSettings
|
<ColumnSettings
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
|||||||
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
|
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
|
||||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||||
import StatusDot from '../../components/task/StatusDot.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)
|
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
|
||||||
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
|
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 [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||||
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
|
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
|
||||||
|
|
||||||
|
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡 (bug#4)
|
||||||
|
const kb = useKeyboardInset()
|
||||||
|
// 返回键: 先关详情弹层, 不离开任务页
|
||||||
|
useOverlayBack(true, onClose)
|
||||||
|
|
||||||
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
|
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
|
||||||
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.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
|
visible
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
position="bottom"
|
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-title">{task.title}</div>
|
||||||
<div className="sheet-sub">
|
<div className="sheet-sub">
|
||||||
@@ -223,6 +237,10 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
|
|||||||
maxLength={200}
|
maxLength={200}
|
||||||
onChange={(e) => setNewSub(e.target.value)}
|
onChange={(e) => setNewSub(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
|
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
|
||||||
|
onFocus={(e) => {
|
||||||
|
// 键盘弹出动画 (~300ms) 后把输入框滚到可视区中部, 双保险防遮挡
|
||||||
|
setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
|
import { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
|
||||||
import PageHeader from '../../components/PageHeader.jsx'
|
import PageHeader from '../../components/PageHeader.jsx'
|
||||||
import { projectApi } from '../../api/task.js'
|
import { projectApi } from '../../api/task.js'
|
||||||
import {
|
import {
|
||||||
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
|
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
|
||||||
|
getRecentIds, getRecentOrder, setRecentOrder,
|
||||||
} from './prefs.js'
|
} from './prefs.js'
|
||||||
import RecentProjects from '../../components/task/RecentProjects.jsx'
|
import RecentProjects from '../../components/task/RecentProjects.jsx'
|
||||||
import ProjectView from './ProjectView.jsx'
|
import ProjectView from './ProjectView.jsx'
|
||||||
|
|
||||||
// 任务模块容器 (T-PM-06, T-VW-08/09)
|
// 任务模块容器 (T-PM-06, T-VW-08/09)
|
||||||
// /task 首屏: 上 = 最近项目条(3 个 + 更多入口), 下 = 当前项目的任务管理
|
// /task 首屏: 上 = 最近项目条(3 个 + 更多入口), 下 = 当前项目的任务管理
|
||||||
// /task/list 完整项目列表(从「更多项目」进入)
|
// /task/list 完整项目列表(从「更多项目」进入; 支持拖动排序 + 新建)
|
||||||
// /task/:pid 打开指定项目(深链), 布局同首屏
|
// /task/:pid 打开指定项目(深链), 布局同首屏
|
||||||
export default function TaskPage() {
|
export default function TaskPage() {
|
||||||
const { pid } = useParams()
|
const { pid } = useParams()
|
||||||
@@ -21,6 +22,7 @@ export default function TaskPage() {
|
|||||||
const [projects, setProjects] = useState(null)
|
const [projects, setProjects] = useState(null)
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const [currentId, setCurrentId] = useState(null)
|
const [currentId, setCurrentId] = useState(null)
|
||||||
|
const [createSignal, setCreateSignal] = useState(0) // 通知项目列表弹起新建对话框
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setErr('')
|
setErr('')
|
||||||
@@ -66,6 +68,9 @@ export default function TaskPage() {
|
|||||||
<div className="navbar-sub task-projbar">
|
<div className="navbar-sub task-projbar">
|
||||||
<span className="back" onClick={() => navigate('/task')}>‹</span>
|
<span className="back" onClick={() => navigate('/task')}>‹</span>
|
||||||
<span className="title">我的项目</span>
|
<span className="title">我的项目</span>
|
||||||
|
<div className="right">
|
||||||
|
<span className="mini-btn solid" onClick={() => setCreateSignal((v) => v + 1)}>+ 新建</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-content">
|
<div className="task-content">
|
||||||
<ProjectList
|
<ProjectList
|
||||||
@@ -74,6 +79,7 @@ export default function TaskPage() {
|
|||||||
onRetry={load}
|
onRetry={load}
|
||||||
onOpen={(id) => { selectProject(id); navigate('/task') }}
|
onOpen={(id) => { selectProject(id); navigate('/task') }}
|
||||||
onChanged={load}
|
onChanged={load}
|
||||||
|
createSignal={createSignal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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 [creating, setCreating] = useState(false)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
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) {
|
if (err) {
|
||||||
return (
|
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>
|
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)
|
const archived = projects.filter((p) => p.archived)
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
@@ -159,14 +193,92 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
// ---- 长按拖动排序 (纵向): 复用首屏卡片同一套 prefs.recentOrder ----
|
||||||
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
|
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 (
|
return (
|
||||||
<>
|
<div ref={listRef}>
|
||||||
<div style={{ height: 8 }} />
|
<div className="rp-drag-tip" style={{ padding: '8px 14px 6px' }}>
|
||||||
|
长按项目可拖动排序(本机记忆,与首屏最近项目一致)
|
||||||
|
</div>
|
||||||
{active.map((p) => (
|
{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) }}>
|
<div className="pico" style={{ background: colorOf(p) }}>
|
||||||
{(p.name || '?').slice(0, 1)}
|
{(p.name || '?').slice(0, 1)}
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +291,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
|||||||
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
|
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pcount">›</div>
|
<div className="pcount">⠿</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -223,6 +335,6 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
|||||||
]}
|
]}
|
||||||
onClose={() => setCreating(false)}
|
onClose={() => setCreating(false)}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
||||||
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
|
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
|
||||||
@@ -1131,3 +1132,6 @@ textarea.inp { resize: vertical; }
|
|||||||
.task-projbar .title { font-size: 15px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user