fix(V0.2): 三轮反馈 5 项 —— 回滚键盘方案 / 列表卡片拖动 / 看板防误触 / 横滑整屏 / 详情页恢复
Docker Build and Push / build-image (push) Successful in 4m9s
Docker Build and Push / build-image (push) Successful in 4m9s
1. 回滚键盘遮挡统一方案 (真机测试不通过, 需求搁置): 恢复 TaskDetail/ UserPicker/CopyTaskPopup/ProjectManage 与 useKeyboardInset 到 9fea1a5^, 删除 useKeyboardInsetWatcher / .kb-safe / interactive-widget=resizes-content 2. 任务详情页样式随 1 一并恢复提交前状态 (含底部拷贝/删除按钮) 3. 列表视图换列改为卡片拖动: 新 useRowDrag (长按 400ms 行抬起, 左右 80px/列步进, 行尾徽标显示目标列, 松手落列), 移除长按 ActionSheet —— 旧长按与复选框/行点击互相误触, 抬手合成 click 落在控件上触发状态切换 4. 看板拖动防误触: 任务区禁长按文字选择与系统菜单 (输入框除外); pointercancel 只放弃不提交; 拖动结束 350ms 内抑制 click 误开详情; 状态点/⋯/子任务行 pointerdown 不冒泡, 不发起拖动 5. 看板横滑扩展到整屏: .page-transition 补 height:100% 打通 app-main → page-with-navbar 百分比链, board-wrap 撑满剩余高度 已验证: kb-safe/Watcher/onLongPress 残留引用清零, 逐文件 diff 审阅通过; 本机无 Node 运行时, 未跑 vite build
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#1f2937" />
|
<meta name="theme-color" content="#1f2937" />
|
||||||
<title>iBoard - 经济数据与策略</title>
|
<title>iBoard - 经济数据与策略</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import MePage from './pages/MePage.jsx'
|
|||||||
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
||||||
import TaskPage from './pages/task/TaskPage.jsx'
|
import TaskPage from './pages/task/TaskPage.jsx'
|
||||||
import { useAuth } from './auth/AuthContext.jsx'
|
import { useAuth } from './auth/AuthContext.jsx'
|
||||||
import useKeyboardInsetWatcher from './hooks/useKeyboardInsetWatcher.js'
|
|
||||||
|
|
||||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||||
const HOME_PATH = '/checkin'
|
const HOME_PATH = '/checkin'
|
||||||
@@ -31,8 +30,6 @@ function RequireAuth({ children }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// 键盘插入量全局监听: 写入 --kb-inset, 弹层用 .kb-safe 类即可避让键盘 (重点设计 §7.2)
|
|
||||||
useKeyboardInsetWatcher()
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
|
|||||||
<Popup
|
<Popup
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||||
|
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">跨项目拷贝</div>
|
<div className="sheet-title">跨项目拷贝</div>
|
||||||
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export default function TaskCard({
|
|||||||
<span
|
<span
|
||||||
className="bstat"
|
className="bstat"
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
title="点按切换完成"
|
title="点按切换完成"
|
||||||
>
|
>
|
||||||
<StatusDot status={task.status} />
|
<StatusDot status={task.status} />
|
||||||
@@ -42,6 +43,7 @@ export default function TaskCard({
|
|||||||
<span
|
<span
|
||||||
className="bmore-btn"
|
className="bmore-btn"
|
||||||
onClick={(e) => { e.stopPropagation(); onMore(task) }}
|
onClick={(e) => { e.stopPropagation(); onMore(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>⋯</span>
|
>⋯</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +53,7 @@ export default function TaskCard({
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||||
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span className="sbox" />
|
<span className="sbox" />
|
||||||
<span className="stitle">{c.title}</span>
|
<span className="stitle">{c.title}</span>
|
||||||
|
|||||||
@@ -1,49 +1,25 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
|
||||||
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
||||||
// 长按 500ms → onLongPress (移动到列菜单); 点击其他区域 → onClick (编辑)
|
// 点击行体 → 打开详情; 长按 400ms 后左右拖动换列 (bindDrag 来自 useRowDrag)
|
||||||
const LONG_PRESS = 500
|
// 复选框 / ⋯ 是独立控件: pointerdown 不冒泡, 不发起拖动,
|
||||||
|
// 避免长按行后抬手时合成的 click 落在控件上误触状态切换
|
||||||
export default function TaskRow({
|
export default function TaskRow({
|
||||||
task, onToggle, onClick, onMore, onLongPress, indent = false, children,
|
task, onToggle, onClick, onMore, bindDrag, dragTarget, indent = false, children,
|
||||||
}) {
|
}) {
|
||||||
const timer = useRef(null)
|
|
||||||
const moved = useRef(false)
|
|
||||||
|
|
||||||
const clear = () => { if (timer.current) { clearTimeout(timer.current); timer.current = null } }
|
|
||||||
useEffect(() => () => clear(), [])
|
|
||||||
|
|
||||||
const startPress = () => {
|
|
||||||
moved.current = false
|
|
||||||
clear()
|
|
||||||
if (!onLongPress) return
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
if (!moved.current) {
|
|
||||||
if (navigator.vibrate) navigator.vibrate(15)
|
|
||||||
onLongPress(task)
|
|
||||||
}
|
|
||||||
}, LONG_PRESS)
|
|
||||||
}
|
|
||||||
const cancelPress = () => clear()
|
|
||||||
const markMoved = () => { moved.current = true; clear() }
|
|
||||||
|
|
||||||
const done = task.status === 'done'
|
const done = task.status === 'done'
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}${dragTarget ? ' dragging' : ''}`}
|
||||||
data-task-id={indent ? undefined : task.id}
|
data-task-id={indent ? undefined : task.id}
|
||||||
onClick={() => { if (!moved.current) onClick?.(task) }}
|
onClick={onClick}
|
||||||
onTouchStart={startPress}
|
{...(!indent && bindDrag ? bindDrag(task) : {})}
|
||||||
onTouchMove={markMoved}
|
|
||||||
onTouchEnd={cancelPress}
|
|
||||||
onMouseDown={startPress}
|
|
||||||
onMouseUp={cancelPress}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="box"
|
className="box"
|
||||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
||||||
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||||
@@ -72,7 +48,16 @@ export default function TaskRow({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-right">
|
<div className="task-right">
|
||||||
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
|
{dragTarget && (
|
||||||
|
<span className={`drag-pill${dragTarget.id === task.columnId ? ' cur' : ''}`}>
|
||||||
|
→ {dragTarget.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="more"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onMore?.(task) }}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
>⋯</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function UserPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe">
|
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
|
||||||
<div className="sheet-title">{title}</div>
|
<div className="sheet-title">{title}</div>
|
||||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做)
|
// 看板卡片列拖动 (重点设计 §7.1)
|
||||||
// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现):
|
// 方案要点:
|
||||||
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
||||||
// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消)
|
// 2. 长按 400ms 激活 (激活前移动 >10px 视为滚动, 取消本次拖动)
|
||||||
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证
|
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名)
|
||||||
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
||||||
// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列
|
// 指针靠近容器左右边缘时 rAF 自动横滚, 可拖到屏幕外的列
|
||||||
// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交;
|
// 5. 松手落到目标列 (≠原列) 才提交; pointercancel (浏览器接管手势,
|
||||||
// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」
|
// 如触发纵向滚动) 只放弃不提交
|
||||||
|
// 6. 防误触: 卡片禁长按系统菜单(contextmenu); 拖动结束后 350ms 内的
|
||||||
|
// click 视为拖动余波, 不打开详情 (justEnded)
|
||||||
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
|
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
|
||||||
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
|
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
|
||||||
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
|
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
|
||||||
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 })
|
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 })
|
||||||
|
const lastEnd = useRef(0) // 拖动结束时刻
|
||||||
|
|
||||||
const stopAutoScroll = () => {
|
const stopAutoScroll = () => {
|
||||||
st.current.autoScroll = 0
|
st.current.autoScroll = 0
|
||||||
@@ -29,6 +32,9 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
|
|
||||||
useEffect(() => () => cleanup(), [cleanup])
|
useEffect(() => () => cleanup(), [cleanup])
|
||||||
|
|
||||||
|
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
||||||
|
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
||||||
|
|
||||||
const hitColumn = (x, y) => {
|
const hitColumn = (x, y) => {
|
||||||
const el = document.elementFromPoint(x, y)
|
const el = document.elementFromPoint(x, y)
|
||||||
const colEl = el?.closest?.('[data-column-id]')
|
const colEl = el?.closest?.('[data-column-id]')
|
||||||
@@ -54,17 +60,6 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
st.current.raf = requestAnimationFrame(step)
|
st.current.raf = requestAnimationFrame(step)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPointerDown = (task) => (ev) => {
|
|
||||||
if (ev.button !== undefined && ev.button !== 0) return
|
|
||||||
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false }
|
|
||||||
// 长按计时: 期间移动超容差(用户在滚动)则取消
|
|
||||||
st.current.timer = setTimeout(() => {
|
|
||||||
st.current.active = true
|
|
||||||
setDragTask(task)
|
|
||||||
if (navigator.vibrate) navigator.vibrate(15)
|
|
||||||
}, 400)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onPointerMove = (ev) => {
|
const onPointerMove = (ev) => {
|
||||||
const s = st.current
|
const s = st.current
|
||||||
if (!s.active) {
|
if (!s.active) {
|
||||||
@@ -82,6 +77,7 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
const onPointerUp = (ev) => {
|
const onPointerUp = (ev) => {
|
||||||
const s = st.current
|
const s = st.current
|
||||||
if (s.active) {
|
if (s.active) {
|
||||||
|
lastEnd.current = Date.now()
|
||||||
const col = hitColumn(ev.clientX, ev.clientY)
|
const col = hitColumn(ev.clientX, ev.clientY)
|
||||||
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col)
|
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col)
|
||||||
}
|
}
|
||||||
@@ -91,24 +87,37 @@ export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' }
|
|||||||
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
||||||
const bind = useCallback((task) => ({
|
const bind = useCallback((task) => ({
|
||||||
onPointerDown: (ev) => {
|
onPointerDown: (ev) => {
|
||||||
// 捕获前先让事件正常冒泡(点击等); 只做长按计时
|
if (ev.button !== undefined && ev.button !== 0) return
|
||||||
onPointerDown(task)(ev)
|
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, active: false }
|
||||||
// 捕获: 后续 move/up 都派发给该元素, 不丢
|
// 长按计时: 期间移动超容差(用户在滚动)则取消
|
||||||
|
st.current.timer = setTimeout(() => {
|
||||||
|
st.current.active = true
|
||||||
|
setDragTask(task)
|
||||||
|
if (navigator.vibrate) navigator.vibrate(15)
|
||||||
|
}, 400)
|
||||||
|
// 捕获: 后续 move/up/cancel 都派发给该元素, 不丢
|
||||||
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
||||||
const el = ev.currentTarget
|
const el = ev.currentTarget
|
||||||
const move = (e2) => onPointerMove(e2)
|
const move = (e2) => onPointerMove(e2)
|
||||||
const up = (e2) => {
|
const up = (e2) => { onPointerUp(e2); detach() }
|
||||||
onPointerUp(e2)
|
const cancel = () => {
|
||||||
|
if (st.current.active) lastEnd.current = Date.now()
|
||||||
|
cleanup()
|
||||||
|
detach()
|
||||||
|
}
|
||||||
|
const detach = () => {
|
||||||
el.removeEventListener('pointermove', move)
|
el.removeEventListener('pointermove', move)
|
||||||
el.removeEventListener('pointerup', up)
|
el.removeEventListener('pointerup', up)
|
||||||
el.removeEventListener('pointercancel', up)
|
el.removeEventListener('pointercancel', cancel)
|
||||||
}
|
}
|
||||||
el.addEventListener('pointermove', move)
|
el.addEventListener('pointermove', move)
|
||||||
el.addEventListener('pointerup', up)
|
el.addEventListener('pointerup', up)
|
||||||
el.addEventListener('pointercancel', up)
|
el.addEventListener('pointercancel', cancel)
|
||||||
},
|
},
|
||||||
|
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
|
||||||
|
onContextMenu: (ev) => ev.preventDefault(),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}), [])
|
}), [])
|
||||||
|
|
||||||
return { dragTask, overCol, bind, cancel: cleanup }
|
return { dragTask, overCol, bind, cancel: cleanup, justEnded }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// 列表视图卡片拖动换列 (2026-10-24, 替代「长按 → ActionSheet」:
|
||||||
|
// 旧方案长按与复选框/行点击互相误触, 且菜单弹出时手指还按在屏上, 体验割裂)
|
||||||
|
// 手势与看板一致: 长按 400ms 行原位抬起(震动), 之后左右拖动;
|
||||||
|
// 水平位移按 80px/列 步进切换目标列 (顺序同列表分组), 行尾实时显示
|
||||||
|
// 目标列名徽标, 松手落到目标列 (≠当前列才提交)。
|
||||||
|
// touch-action: pan-y —— 横向位移归拖动, 纵向仍归列表滚动;
|
||||||
|
// 激活后浏览器若接管纵向滚动 (pointercancel) 只放弃不提交。
|
||||||
|
// 注: 列表只展示有任务的列, 空列拖不进去 (用看板视图可拖空列)。
|
||||||
|
const STEP = 80
|
||||||
|
|
||||||
|
export default function useRowDrag({ columns, onDrop }) {
|
||||||
|
// { task, fromIdx, toIdx }: 拖动中的行 + 当前目标列下标 (徽标渲染)
|
||||||
|
const [drag, setDrag] = useState(null)
|
||||||
|
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, fromIdx: 0, toIdx: 0 })
|
||||||
|
const lastEnd = useRef(0) // 拖动结束时刻
|
||||||
|
|
||||||
|
// 目标列顺序 = 列表分组顺序 (sortOrder)
|
||||||
|
const ordered = useMemo(
|
||||||
|
() => [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||||
|
[columns],
|
||||||
|
)
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
|
||||||
|
st.current.active = false
|
||||||
|
setDrag(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => () => cleanup(), [cleanup])
|
||||||
|
|
||||||
|
/** 拖动刚结束 (350ms 内): 抑制松手合成的 click 误开详情页 */
|
||||||
|
const justEnded = useCallback(() => Date.now() - lastEnd.current < 350, [])
|
||||||
|
|
||||||
|
const onMove = (ev) => {
|
||||||
|
const s = st.current
|
||||||
|
if (!s.active) {
|
||||||
|
if (s.timer && (Math.abs(ev.clientX - s.startX) > 10 || Math.abs(ev.clientY - s.startY) > 10)) {
|
||||||
|
clearTimeout(s.timer); s.timer = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev.preventDefault()
|
||||||
|
const to = Math.max(0, Math.min(ordered.length - 1, s.fromIdx + Math.round((ev.clientX - s.startX) / STEP)))
|
||||||
|
if (to !== s.toIdx) {
|
||||||
|
s.toIdx = to
|
||||||
|
setDrag({ task: s.task, fromIdx: s.fromIdx, toIdx: to })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
const s = st.current
|
||||||
|
if (s.active && s.task) {
|
||||||
|
lastEnd.current = Date.now()
|
||||||
|
const target = ordered[s.toIdx]
|
||||||
|
if (target && target.id !== s.task.columnId) onDrop?.(s.task, target.id)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定到列表行: 行元素需设置 touch-action: pan-y */
|
||||||
|
const bind = useCallback((task) => ({
|
||||||
|
onPointerDown: (ev) => {
|
||||||
|
if (ev.button !== undefined && ev.button !== 0) return
|
||||||
|
const fromIdx = ordered.findIndex((c) => c.id === task.columnId)
|
||||||
|
if (fromIdx < 0) return // 不在任何列 (如「其他」组) 的任务不支持拖动
|
||||||
|
st.current = { ...st.current, startX: ev.clientX, startY: ev.clientY, task, fromIdx, toIdx: fromIdx, active: false }
|
||||||
|
st.current.timer = setTimeout(() => {
|
||||||
|
st.current.active = true
|
||||||
|
if (navigator.vibrate) navigator.vibrate(15)
|
||||||
|
setDrag({ task, fromIdx, toIdx: fromIdx })
|
||||||
|
}, 400)
|
||||||
|
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
||||||
|
const el = ev.currentTarget
|
||||||
|
const move = (e2) => onMove(e2)
|
||||||
|
const up = () => { onUp(); detach() }
|
||||||
|
const cancel = () => {
|
||||||
|
if (st.current.active) lastEnd.current = Date.now()
|
||||||
|
cleanup()
|
||||||
|
detach()
|
||||||
|
}
|
||||||
|
const detach = () => {
|
||||||
|
el.removeEventListener('pointermove', move)
|
||||||
|
el.removeEventListener('pointerup', up)
|
||||||
|
el.removeEventListener('pointercancel', cancel)
|
||||||
|
}
|
||||||
|
el.addEventListener('pointermove', move)
|
||||||
|
el.addEventListener('pointerup', up)
|
||||||
|
el.addEventListener('pointercancel', cancel)
|
||||||
|
},
|
||||||
|
// 长按不弹系统菜单 (配合 CSS user-select:none, 防长按变文字选择)
|
||||||
|
onContextMenu: (ev) => ev.preventDefault(),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}), [ordered, onDrop])
|
||||||
|
|
||||||
|
return { drag, bind, cancel: cleanup, justEnded }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
// 键盘遮挡统一方案 (重点设计 §7.2):
|
|
||||||
// 全局唯一监听器, 把键盘插入量写入 CSS 变量 --kb-inset (0 或 px 字符串),
|
|
||||||
// 任何底部弹层只要挂 .kb-safe 类即可被抬到键盘上方, 组件不再各自接键盘逻辑。
|
|
||||||
// 信号源 (双保险):
|
|
||||||
// 1) visualViewport 的 resize/scroll —— Android WebView 键盘弹出时它先变小
|
|
||||||
// 2) window resize —— 配合 index.html 的 interactive-widget=resizes-content,
|
|
||||||
// Chromium 系 WebView 会整体收缩布局视口
|
|
||||||
// 基线: 记录见过的最大视口高 (键盘收起时), 避免"弹出键盘后基线被拉低"的漂移。
|
|
||||||
export default function useKeyboardInsetWatcher() {
|
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement
|
|
||||||
const vv = window.visualViewport
|
|
||||||
let maxH = window.innerHeight
|
|
||||||
|
|
||||||
const calc = () => {
|
|
||||||
const h = vv ? vv.height : window.innerHeight
|
|
||||||
if (h > maxH) maxH = h
|
|
||||||
const inset = Math.max(0, Math.round(maxH - h - (vv ? vv.offsetTop : 0)))
|
|
||||||
root.style.setProperty('--kb-inset', inset > 90 ? inset + 'px' : '0px')
|
|
||||||
}
|
|
||||||
|
|
||||||
calc()
|
|
||||||
window.addEventListener('resize', calc)
|
|
||||||
vv?.addEventListener('resize', calc)
|
|
||||||
vv?.addEventListener('scroll', calc)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', calc)
|
|
||||||
vv?.removeEventListener('resize', calc)
|
|
||||||
vv?.removeEventListener('scroll', calc)
|
|
||||||
root.style.setProperty('--kb-inset', '0px')
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
@@ -40,7 +40,7 @@ export default function BoardView({
|
|||||||
task={t}
|
task={t}
|
||||||
dragging={drag.dragTask?.id === t.id}
|
dragging={drag.dragTask?.id === t.id}
|
||||||
bindDrag={drag.bind}
|
bindDrag={drag.bind}
|
||||||
onClick={() => { if (!drag.dragTask) onOpen?.(t) }}
|
onClick={() => { if (!drag.dragTask && !drag.justEnded()) onOpen?.(t) }}
|
||||||
onToggleSub={onToggleSub}
|
onToggleSub={onToggleSub}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onMore={onMore}
|
onMore={onMore}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import TaskRow from '../../components/task/TaskRow.jsx'
|
import TaskRow from '../../components/task/TaskRow.jsx'
|
||||||
|
import useRowDrag from '../../components/task/useRowDrag.js'
|
||||||
|
|
||||||
// 列表视图 (T-VW-01, T-ST-04/05)
|
// 列表视图 (T-VW-01, T-ST-04/05)
|
||||||
// 按看板列分组展示; 子任务缩进 + 折叠展开
|
// 按看板列分组展示; 子任务缩进 + 折叠展开
|
||||||
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口
|
// 换列手势与看板一致: 长按 400ms 行抬起, 左右拖动选列, 松手落列
|
||||||
|
// (列表只展示有任务的列, 空列需到看板视图拖入)
|
||||||
export default function ListView({
|
export default function ListView({
|
||||||
tasks, columns, onToggle, onOpen, onMore, onLongPress,
|
tasks, columns, onToggle, onOpen, onMore, onMoveColumn,
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState({})
|
const [collapsed, setCollapsed] = useState({})
|
||||||
|
const drag = useRowDrag({ columns, onDrop: onMoveColumn })
|
||||||
|
|
||||||
if (!tasks || tasks.length === 0) {
|
if (!tasks || tasks.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -41,19 +44,21 @@ export default function ListView({
|
|||||||
{g.list.map((t) => {
|
{g.list.map((t) => {
|
||||||
const kids = t.children || []
|
const kids = t.children || []
|
||||||
const isCollapsed = !!collapsed[t.id]
|
const isCollapsed = !!collapsed[t.id]
|
||||||
|
const dragging = drag.drag?.task?.id === t.id ? drag.drag : null
|
||||||
return (
|
return (
|
||||||
<div key={t.id}>
|
<div key={t.id}>
|
||||||
<TaskRow
|
<TaskRow
|
||||||
task={t}
|
task={t}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onClick={() => onOpen(t)}
|
onClick={() => { if (!drag.justEnded()) onOpen(t) }}
|
||||||
onMore={onMore}
|
onMore={onMore}
|
||||||
onLongPress={onLongPress}
|
bindDrag={drag.bind}
|
||||||
|
dragTarget={dragging ? orderedCols[dragging.toIdx] : null}
|
||||||
/>
|
/>
|
||||||
{kids.length > 0 && (
|
{kids.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{!isCollapsed && kids.map((c) => (
|
{!isCollapsed && kids.map((c) => (
|
||||||
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} onLongPress={onLongPress} />
|
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
|
||||||
))}
|
))}
|
||||||
<div
|
<div
|
||||||
className="collapse"
|
className="collapse"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
|||||||
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
|
import { Dialog, Input, Popup, 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 useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
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'
|
||||||
@@ -23,6 +24,8 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
|
|
||||||
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
|
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
|
||||||
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
|
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
|
||||||
|
// 键盘弹出插入量: 信息块编辑弹层用
|
||||||
|
const kb = useKeyboardInset()
|
||||||
|
|
||||||
if (view === 'columns') {
|
if (view === 'columns') {
|
||||||
return (
|
return (
|
||||||
@@ -230,7 +233,13 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
visible={!!noteEdit}
|
visible={!!noteEdit}
|
||||||
onMaskClick={() => setNoteEdit(null)}
|
onMaskClick={() => setNoteEdit(null)}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
borderTopRightRadius: 12,
|
||||||
|
maxHeight: '86vh',
|
||||||
|
overflowY: 'auto',
|
||||||
|
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
|
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
|
||||||
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
|
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
|
||||||
@@ -249,7 +258,6 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
<textarea
|
<textarea
|
||||||
className="inp"
|
className="inp"
|
||||||
rows={10}
|
rows={10}
|
||||||
onFocus={(e) => { setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) }}
|
|
||||||
placeholder="支持多行长文本…"
|
placeholder="支持多行长文本…"
|
||||||
value={noteEdit?.content || ''}
|
value={noteEdit?.content || ''}
|
||||||
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
|
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
|
||||||
|
|||||||
@@ -127,21 +127,6 @@ 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) {
|
function openTaskMenu(task) {
|
||||||
ActionSheet.show({
|
ActionSheet.show({
|
||||||
@@ -236,7 +221,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
onToggle={toggleDone}
|
onToggle={toggleDone}
|
||||||
onOpen={(t) => setOpenTask(t)}
|
onOpen={(t) => setOpenTask(t)}
|
||||||
onMore={openTaskMenu}
|
onMore={openTaskMenu}
|
||||||
onLongPress={openMoveMenu}
|
onMoveColumn={moveToColumn}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BoardView
|
<BoardView
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
import { ActionSheet, Dialog, Popup, Toast } from 'antd-mobile'
|
||||||
import { taskApi } from '../../api/task.js'
|
import { taskApi } from '../../api/task.js'
|
||||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||||
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 CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
|
||||||
|
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||||
|
|
||||||
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
|
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
|
||||||
@@ -23,8 +25,11 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
const [newSub, setNewSub] = useState('')
|
const [newSub, setNewSub] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||||
|
const [copyOpen, setCopyOpen] = useState(false)
|
||||||
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
||||||
|
|
||||||
|
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
|
||||||
|
const kb = useKeyboardInset()
|
||||||
// 返回键: 先关详情弹层, 不离开任务页
|
// 返回键: 先关详情弹层, 不离开任务页
|
||||||
useOverlayBack(true, onClose)
|
useOverlayBack(true, onClose)
|
||||||
|
|
||||||
@@ -127,12 +132,35 @@ 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 (
|
return (
|
||||||
<Popup
|
<Popup
|
||||||
visible
|
visible
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
className="kb-safe"
|
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)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">{task.title}</div>
|
<div className="sheet-title">{task.title}</div>
|
||||||
<div className="sheet-sub">
|
<div className="sheet-sub">
|
||||||
@@ -250,6 +278,10 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</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
|
<UserPicker
|
||||||
visible={picker === 'assignee'}
|
visible={picker === 'assignee'}
|
||||||
@@ -269,12 +301,22 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
|
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CopyTaskPopup
|
||||||
|
task={task}
|
||||||
|
visible={copyOpen}
|
||||||
|
onClose={() => setCopyOpen(false)}
|
||||||
|
onDone={onChanged}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 转为子任务: 从项目内顶层任务中选父任务 */}
|
{/* 转为子任务: 从项目内顶层任务中选父任务 */}
|
||||||
<Popup
|
<Popup
|
||||||
visible={parentPick}
|
visible={parentPick}
|
||||||
onMaskClick={() => setParentPick(false)}
|
onMaskClick={() => setParentPick(false)}
|
||||||
className="kb-safe"
|
bodyStyle={{
|
||||||
bodyStyle={{ maxHeight: '70vh' }}
|
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||||
|
maxHeight: '70vh', overflowY: 'auto',
|
||||||
|
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="sheet-title">转为子任务</div>
|
<div className="sheet-title">转为子任务</div>
|
||||||
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
|
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
|
||||||
|
|||||||
+30
-20
@@ -72,6 +72,11 @@ input, textarea, select { font: inherit; }
|
|||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 页面容器: 高度打通 app-main → page-with-navbar 的百分比链。
|
||||||
|
没有这行 height:100%, page-with-navbar 的 height:100% 对 auto 高度的父级失效,
|
||||||
|
整页塌成内容高 —— 看板 board-wrap 随之只卡片区那么高, 横滑只在有任务的区域生效 */
|
||||||
|
.page-transition { height: 100%; }
|
||||||
|
|
||||||
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
|
/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */
|
||||||
.page-transition.active {
|
.page-transition.active {
|
||||||
animation: page-in 0.28s ease;
|
animation: page-in 0.28s ease;
|
||||||
@@ -989,7 +994,6 @@ body.me-safe-top .app-safe-top { height: 0; }
|
|||||||
background: #fff; border-radius: 8px; padding: 9px 10px;
|
background: #fff; border-radius: 8px; padding: 9px 10px;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.bcard.dragging { opacity: .55; transform: scale(.97) rotate(-1deg); box-shadow: 0 8px 18px rgba(15,23,42,.16); }
|
|
||||||
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||||
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
||||||
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
||||||
@@ -1134,7 +1138,7 @@ textarea.inp { resize: vertical; }
|
|||||||
.pv-embed .task-content { flex: 1 1 auto; }
|
.pv-embed .task-content { flex: 1 1 auto; }
|
||||||
|
|
||||||
/* ---- 项目列表拖动排序 (bug#3) ---- */
|
/* ---- 项目列表拖动排序 (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; }
|
.proj-item.dragging { opacity: .65; transform: scale(.98); box-shadow: 0 8px 18px rgba(15,23,42,.16); z-index: 5; position: relative; }
|
||||||
|
|
||||||
/* ---- 项目信息块 (T-PJ-13) ---- */
|
/* ---- 项目信息块 (T-PJ-13) ---- */
|
||||||
.note-row { cursor: pointer; }
|
.note-row { cursor: pointer; }
|
||||||
@@ -1142,7 +1146,7 @@ textarea.inp { resize: vertical; }
|
|||||||
.note-preview {
|
.note-preview {
|
||||||
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
|
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
|
||||||
text-overflow: ellipsis; white-space: nowrap;
|
text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
|
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
|
||||||
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
|
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
|
||||||
@@ -1155,21 +1159,27 @@ textarea.inp { resize: vertical; }
|
|||||||
|
|
||||||
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
|
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
|
||||||
.as-extra { padding: 4px 0 8px; }
|
.as-extra { padding: 4px 0 8px; }
|
||||||
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
/* ---- 键盘避让弹层 (重点设计 §7.2) ----
|
/* ---- 拖动手势 (重点设计 §7.1) ----
|
||||||
底部弹层统一挂 .kb-safe: 底部留白 = 安全区 + 20px + 键盘插入量(--kb-inset,
|
看板卡片 / 列表行: 纵向滚动归浏览器(pan-y), 水平位移留给拖动换列。
|
||||||
由 App 级 useKeyboardInsetWatcher 写入)。键盘弹出时内容抬到键盘上方,
|
任务区整体禁用长按文字选择与系统菜单, 避免长按拖动变成选中文字 (输入框除外)。 */
|
||||||
弹层自身 max-height 用 dvh 随视口收缩并内部滚动。 */
|
.task-content {
|
||||||
.kb-safe {
|
-webkit-user-select: none; user-select: none;
|
||||||
border-top-left-radius: 12px;
|
-webkit-touch-callout: none;
|
||||||
border-top-right-radius: 12px;
|
}
|
||||||
max-height: min(86dvh, calc(100dvh - 120px));
|
.task-content input, .task-content textarea { -webkit-user-select: text; user-select: text; }
|
||||||
overflow-y: auto;
|
.bcard, .task-row { touch-action: pan-y; }
|
||||||
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + var(--kb-inset, 0px));
|
.bcard.dragging {
|
||||||
}
|
opacity: .5; transform: scale(.97); position: relative; z-index: 6;
|
||||||
|
box-shadow: 0 10px 22px rgba(15,23,42,.22);
|
||||||
/* ---- 拖动方案 (重点设计 §7.1) ----
|
}
|
||||||
卡片允许纵向滚动(PAN-Y), 水平位移留给拖动; 激活后原位抬起 */
|
.task-row.dragging {
|
||||||
.bcard { touch-action: pan-y; }
|
opacity: .55; transform: scale(.985); position: relative; z-index: 5;
|
||||||
.bcard.dragging { opacity: .5; transform: scale(.97); box-shadow: 0 10px 22px rgba(15,23,42,.22); z-index: 6; }
|
box-shadow: 0 8px 18px rgba(15,23,42,.16);
|
||||||
|
}
|
||||||
|
.drag-pill {
|
||||||
|
font-size: 11px; font-weight: 600; color: #fff; background: var(--color-primary);
|
||||||
|
border-radius: 999px; padding: 2px 9px; flex-shrink: 0; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.drag-pill.cur { background: #c0c7d0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user