fix(V0.2): 二轮评审 6 项 —— BUILD_ID 格式 / 菜单与详情精简 / 拖动重做 / 键盘统一方案
Docker Build and Push / build-image (push) Successful in 4m5s

1. BUILD_ID 改为 yyyyMMddHHmmss (去掉中间点), 仍 UTC+8
2. 任务 ⋯ 菜单去掉顶部任务名标题
3. 任务详情去掉底部「拷贝到其他项目」「删除任务」按钮 (⋯ 菜单已有)
4+6. 键盘遮挡统一方案 (重点设计 §7.2):
   - index.html 加 interactive-widget=resizes-content
   - App 级 useKeyboardInsetWatcher 写 --kb-inset (visualViewport+window 双源)
   - 底部弹层统一 .kb-safe 类 (安全区+20px+键盘插入量, dvh 收缩+内部滚动)
   - TaskDetail/UserPicker/CopyTaskPopup/信息块弹层全部迁移, 删除 per-component hook
   - 信息块 textarea 补 focus scrollIntoView 兜底
5. 拖动方案重做 (重点设计 §7.1):
   - 新 useColumnDrag: Pointer Events + setPointerCapture, 长按400ms原位抬起,
     elementFromPoint 高亮目标列, 边缘 rAF 自动横滚; 移除手指跟随浮层(任务名)
   - 列表行长按 500ms → 「移动到列」ActionSheet (不做自由拖动)
   - 移除「拖到卡片转子任务」误触路径, 统一走详情页层级区
   - 删除旧 useLongPressDrag

已验证: 99 文件语法 0 错误、导入 0 缺失
This commit is contained in:
jif2.zhang
2026-10-24 22:15:52 +08:00
parent 506db0795b
commit 9fea1a5a3a
17 changed files with 313 additions and 289 deletions
+45 -66
View File
@@ -1,78 +1,57 @@
import { useState } from 'react'
import { Toast } from 'antd-mobile'
import TaskCard from '../../components/task/TaskCard.jsx'
import useLongPressDrag from '../../components/task/useLongPressDrag.js'
import useColumnDrag from '../../components/task/useColumnDrag.js'
// 看板视图 (T-BD-01, T-UI-07, T-ST-06/12)
// 列横向滚动; 长按卡片拖拽换列 / 降为子任务
// 看板视图 (T-BD-01, T-UI-07)
// 拖动 = 长按 400ms 激活, 卡片原位抬起, 指针划过列高亮, 松手落列 (重点设计 §7.1)
// 不做"拖到卡片上转子任务" —— 统一走详情页「转为子任务」
export default function BoardView({
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
tasks, columns, onMoveColumn, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
}) {
const [overCol, setOverCol] = useState(null)
const drag = useLongPressDrag({
onDropColumn: (task, columnId) => { setOverCol(null); onMoveColumn?.(task, columnId) },
onDropTask: (task, parentId) => { setOverCol(null); onMakeSubtask?.(task, parentId) },
onReject: (msg) => { setOverCol(null); Toast.show({ content: msg }) },
})
const drag = useColumnDrag({ onDrop: onMoveColumn })
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<>
<div className="board-wrap">
{ordered.map((col) => {
const list = (tasks || []).filter((t) => t.columnId === col.id)
return (
<div
className={`bcol${overCol === col.id ? ' dragover' : ''}`}
key={col.id}
data-column-id={col.id}
onDragEnter={() => setOverCol(col.id)}
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
>
<div className="bcol-head">
<span className="bcol-name">{col.name}</span>
<span className="bcol-cnt">{list.length}</span>
{col.boundStatus ? (
<span className="bcol-bind">
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
</span>
) : (
<span className="bcol-bind off">未绑定</span>
)}
</div>
<div className="bcol-body">
{list.map((t) => (
<TaskCard
key={t.id}
task={t}
dragging={drag.dragging?.task?.id === t.id}
highlight={drag.dragging && drag.dragging.task.id !== t.id && overCol === col.id}
bindDrag={drag.bind}
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
onToggleSub={onToggleSub}
onToggle={onToggle}
onMore={onMore}
/>
))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
</div>
<div className="board-wrap">
{ordered.map((col) => {
const list = (tasks || []).filter((t) => t.columnId === col.id)
return (
<div
className={`bcol${drag.overCol === col.id ? ' dragover' : ''}`}
key={col.id}
data-column-id={col.id}
>
<div className="bcol-head">
<span className="bcol-name">{col.name}</span>
<span className="bcol-cnt">{list.length}</span>
{col.boundStatus ? (
<span className="bcol-bind">
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
</span>
) : (
<span className="bcol-bind off">未绑定</span>
)}
</div>
)
})}
</div>
{drag.dragging && (
<div
className="drag-ghost"
style={{ left: drag.dragging.x, top: drag.dragging.y }}
>
{drag.dragging.task.title}
</div>
)}
</>
<div className="bcol-body">
{list.map((t) => (
<TaskCard
key={t.id}
task={t}
dragging={drag.dragTask?.id === t.id}
bindDrag={drag.bind}
onClick={() => { if (!drag.dragTask) onOpen?.(t) }}
onToggleSub={onToggleSub}
onToggle={onToggle}
onMore={onMore}
/>
))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
</div>
</div>
)
})}
</div>
)
}
+3 -2
View File
@@ -5,7 +5,7 @@ import TaskRow from '../../components/task/TaskRow.jsx'
// 按看板列分组展示; 子任务缩进 + 折叠展开
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口
export default function ListView({
tasks, columns, onToggle, onOpen, onMore,
tasks, columns, onToggle, onOpen, onMore, onLongPress,
}) {
const [collapsed, setCollapsed] = useState({})
@@ -48,11 +48,12 @@ export default function ListView({
onToggle={onToggle}
onClick={() => onOpen(t)}
onMore={onMore}
onLongPress={onLongPress}
/>
{kids.length > 0 && (
<>
{!isCollapsed && kids.map((c) => (
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} onLongPress={onLongPress} />
))}
<div
className="collapse"
+2 -10
View File
@@ -2,7 +2,6 @@ import { useState } from 'react'
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
import ColumnSettings from './ColumnSettings.jsx'
import ProjectMembers from './ProjectMembers.jsx'
@@ -24,8 +23,6 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
// 键盘弹出插入量: 信息块编辑弹层用
const kb = useKeyboardInset()
if (view === 'columns') {
return (
@@ -233,13 +230,7 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
visible={!!noteEdit}
onMaskClick={() => setNoteEdit(null)}
position="bottom"
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
maxHeight: '86vh',
overflowY: 'auto',
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
className="kb-safe"
>
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
@@ -258,6 +249,7 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
<textarea
className="inp"
rows={10}
onFocus={(e) => { setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) }}
placeholder="支持多行长文本…"
value={noteEdit?.content || ''}
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
+16 -17
View File
@@ -114,17 +114,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
} catch { /* ignore */ }
}
/** 拖拽降为子任务 (T-ST-12) */
const makeSubtask = async (task, parentId) => {
try {
await updateTask(task.id, { parentId })
// 成为子任务后不再作为顶层卡片出现
setTasks((prev) => (prev || []).filter((t) => t.id !== task.id))
Toast.show({ content: '已转为子任务' })
load()
} catch { /* 错误已提示 */ }
}
/** 新建任务 */
async function createTask(title, parentId = null) {
const t = String(title || '').trim()
@@ -138,14 +127,24 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
}
}
/** 长按任务 → 移动到列 (重点设计 §7.1: 列表页不做自由拖动, 菜单可靠) */
function openMoveMenu(task) {
ActionSheet.show({
actions: (columns || []).map((c) => ({
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
key: String(c.id),
})),
cancelText: '取消',
onAction: (item) => {
const columnId = Number(item.key)
if (columnId !== task.columnId) moveToColumn(task, columnId)
},
})
}
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
function openTaskMenu(task) {
ActionSheet.show({
extra: (
<div className="as-extra">
<div className="as-title">{task.title}</div>
</div>
),
actions: [
{ text: '编辑', key: 'edit' },
{ text: '拷贝到其他项目', key: 'copy' },
@@ -237,13 +236,13 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onMore={openTaskMenu}
onLongPress={openMoveMenu}
/>
) : (
<BoardView
tasks={visibleTasks}
columns={columns}
onMoveColumn={moveToColumn}
onMakeSubtask={makeSubtask}
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
+4 -46
View File
@@ -1,11 +1,9 @@
import { useEffect, useState } from 'react'
import { ActionSheet, Dialog, Popup, Toast } from 'antd-mobile'
import { ActionSheet, Popup, Toast } from 'antd-mobile'
import { taskApi } from '../../api/task.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import UserPicker from '../../components/task/UserPicker.jsx'
import StatusDot from '../../components/task/StatusDot.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
@@ -25,11 +23,8 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
const [newSub, setNewSub] = useState('')
const [saving, setSaving] = useState(false)
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copyOpen, setCopyOpen] = useState(false)
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
const kb = useKeyboardInset()
// 返回键: 先关详情弹层, 不离开任务页
useOverlayBack(true, onClose)
@@ -132,35 +127,12 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
}
}
async function removeTask() {
const ok = await Dialog.confirm({
content: subs.length
? `删除该任务?将一并删除 ${subs.length} 条子任务。`
: '删除该任务?',
confirmText: '删除',
})
if (!ok) return
try {
await taskApi.remove(task.id)
Toast.show({ content: '已删除' })
onSaved?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<Popup
visible
onMaskClick={onClose}
position="bottom"
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
maxHeight: `calc(88vh - ${kb}px)`,
overflowY: 'auto',
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
className="kb-safe"
>
<div className="sheet-title">{task.title}</div>
<div className="sheet-sub">
@@ -278,10 +250,6 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
</div>
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={() => setCopyOpen(true)}>拷贝到其他项目</div>
<div className="btn-danger" onClick={removeTask}>
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
</div>
<UserPicker
visible={picker === 'assignee'}
@@ -301,22 +269,12 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
/>
<CopyTaskPopup
task={task}
visible={copyOpen}
onClose={() => setCopyOpen(false)}
onDone={onChanged}
/>
{/* 转为子任务: 从项目内顶层任务中选父任务 */}
<Popup
visible={parentPick}
onMaskClick={() => setParentPick(false)}
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
maxHeight: '70vh', overflowY: 'auto',
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
className="kb-safe"
bodyStyle={{ maxHeight: '70vh' }}
>
<div className="sheet-title">转为子任务</div>
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>