fix(V0.2): 二轮评审 6 项 —— BUILD_ID 格式 / 菜单与详情精简 / 拖动重做 / 键盘统一方案
Docker Build and Push / build-image (push) Successful in 4m5s
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:
@@ -423,3 +423,29 @@ POST /api/tasks/:id/copy { projectId: 目标项目 }
|
|||||||
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;保留档位切换任务当场消失并重排 |
|
| **交互验证** | 拖拽换列 / 降为子任务 / 拒绝场景 Toast;保留档位切换任务当场消失并重排 |
|
||||||
| **兼容性** | Android WebView + 移动浏览器(T-NF-02);375 / 390 / 430 三档视口 |
|
| **兼容性** | Android WebView + 移动浏览器(T-NF-02);375 / 390 / 430 三档视口 |
|
||||||
| **两视图一致** | 列表与看板可见条数一致(T-FL-11、T-FL-14);任一处修改另一处即时可见(T-UI-03) |
|
| **两视图一致** | 列表与看板可见条数一致(T-FL-11、T-FL-14);任一处修改另一处即时可见(T-UI-03) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 重点设计(2026-10-24 二轮评审)
|
||||||
|
|
||||||
|
### 7.1 任务拖动方案(重做)
|
||||||
|
|
||||||
|
旧版(手指跟随浮层 + 落点命中即转子任务)在真机上不可靠:浮层遮挡视线、touch 事件与滚动冲突、拖到卡片上误触转子任务。重做后的方案收敛为「**一个可靠手势 + 一个可靠菜单**」:
|
||||||
|
|
||||||
|
| 场景 | 交互 | 实现 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 看板换列 | 长按 400ms 激活(激活前移动 >10px 视为滚动,自动取消)→ 卡片**原位抬起**(不跟随手指、无浮层、无任务名)→ 指针划过列高亮 → 松手落列 | `useColumnDrag`:Pointer Events + `setPointerCapture`(触摸/鼠标统一、捕获后不丢事件、天然吞掉 pointercancel);`elementFromPoint` 命中 `[data-column-id]`;指针靠近容器边缘时 rAF 自动横滚,可拖到屏幕外的列 |
|
||||||
|
| 列表移动 | 长按 500ms → 弹「移动到列」ActionSheet(当前列打勾) | `TaskRow` 长按计时 → `ProjectView.openMoveMenu`;列表是纵向滚动流,不做自由拖动 |
|
||||||
|
| 转为子任务 | 不走拖拽(已移除「拖到卡片上」路径,误触率高) | 详情页「层级」区:选父任务转为子任务 / 一键转为顶层 |
|
||||||
|
|
||||||
|
关键细节:卡片 `touch-action: pan-y`(纵向滚动留给浏览器,水平位移给拖动);激活与提交都以同一元素的 pointer 事件为准;`pointercancel` 与 `pointerup` 同样清理状态。换列后的状态仍由服务端按目标列绑定统一裁决(§2.2 不变)。
|
||||||
|
|
||||||
|
### 7.2 键盘遮挡统一方案
|
||||||
|
|
||||||
|
多个底部弹层(任务详情、信息块编辑、人员选择、拷贝)都出现过"输入框被输入法遮挡"。逐个组件接键盘逻辑不可维护,统一为**一个全局信号 + 一个 CSS 类**:
|
||||||
|
|
||||||
|
1. **信号源**:`App` 级唯一监听器 `useKeyboardInsetWatcher`,同时监听 `visualViewport` 的 resize/scroll 与 `window.resize`(配合 `index.html` 的 `interactive-widget=resizes-content`,Chromium 系 WebView 键盘弹出会整体收缩布局视口);以"见过的最大视口高"为基线计算键盘插入量,写入 CSS 变量 **`--kb-inset`**(>90px 才视为键盘,过滤地址栏抖动)。
|
||||||
|
2. **消费端**:底部弹层统一挂 **`.kb-safe`** 类 —— `padding-bottom: calc(安全区 + 20px + var(--kb-inset))`、`max-height` 用 `dvh` 随视口收缩并内部滚动。组件不再各自 import 键盘 hook、不再写内联 padding。
|
||||||
|
3. **兜底**:长输入区(子任务输入、信息块 textarea)聚焦 300ms 后 `scrollIntoView({block:'center'})`,双保险。
|
||||||
|
|
||||||
|
新增弹层只要挂 `.kb-safe` 即自动获得键盘避让,无需再处理。
|
||||||
|
|||||||
+13
-13
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<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" />
|
<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="theme-color" content="#1f2937" />
|
<meta name="theme-color" content="#1f2937" />
|
||||||
<title>iBoard - 经济数据与策略</title>
|
<title>iBoard - 经济数据与策略</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -8,6 +8,7 @@ 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'
|
||||||
@@ -30,6 +31,8 @@ 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,10 +38,7 @@ export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
|
|||||||
<Popup
|
<Popup
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
bodyStyle={{
|
className="kb-safe"
|
||||||
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>
|
||||||
|
|||||||
@@ -1,16 +1,45 @@
|
|||||||
|
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 (编辑)
|
||||||
|
const LONG_PRESS = 500
|
||||||
|
|
||||||
export default function TaskRow({
|
export default function TaskRow({
|
||||||
task, onToggle, onClick, onMore, indent = false, children,
|
task, onToggle, onClick, onMore, onLongPress, 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' : ''}`}
|
||||||
data-task-id={indent ? undefined : task.id}
|
data-task-id={indent ? undefined : task.id}
|
||||||
onClick={onClick}
|
onClick={() => { if (!moved.current) onClick?.(task) }}
|
||||||
|
onTouchStart={startPress}
|
||||||
|
onTouchMove={markMoved}
|
||||||
|
onTouchEnd={cancelPress}
|
||||||
|
onMouseDown={startPress}
|
||||||
|
onMouseUp={cancelPress}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="box"
|
className="box"
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function UserPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}>
|
<Popup visible={visible} onMaskClick={onClose} position="bottom" className="kb-safe">
|
||||||
<div className="sheet-title">{title}</div>
|
<div className="sheet-title">{title}</div>
|
||||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
// 看板卡片列拖动 (重点设计 §7.1, 2026-10-24 重做)
|
||||||
|
// 方案要点 (替代旧版"手指跟随浮层 + 落点即子任务"的不可靠实现):
|
||||||
|
// 1. Pointer Events + setPointerCapture —— 触摸/鼠标统一, 捕获后不丢事件
|
||||||
|
// 2. 长按 400ms 激活 (先给滚动让路: 激活前移动 >10px 视为滚动并取消)
|
||||||
|
// 3. 激活后卡片原位抬起 (不跟随手指, 无浮层/无任务名), touch-action 由捕获保证
|
||||||
|
// 4. 拖动中持续 elementFromPoint 命中 [data-column-id] 高亮目标列;
|
||||||
|
// 指针靠近容器左右边缘时自动横向滚动, 可拖到屏幕外的列
|
||||||
|
// 5. 松手/取消(pointercancel 也要处理) → 落到目标列(≠原列)才提交;
|
||||||
|
// 拖到卡片上不再转子任务 —— 统一走详情页「转为子任务」
|
||||||
|
export default function useColumnDrag({ onDrop, scrollSelector = '.board-wrap' } = {}) {
|
||||||
|
const [dragTask, setDragTask] = useState(null) // 拖动中的任务 (视觉态)
|
||||||
|
const [overCol, setOverCol] = useState(null) // 高亮的目标列 id
|
||||||
|
const st = useRef({ timer: null, active: false, startX: 0, startY: 0, task: null, autoScroll: 0, raf: 0 })
|
||||||
|
|
||||||
|
const stopAutoScroll = () => {
|
||||||
|
st.current.autoScroll = 0
|
||||||
|
if (st.current.raf) { cancelAnimationFrame(st.current.raf); st.current.raf = 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (st.current.timer) { clearTimeout(st.current.timer); st.current.timer = null }
|
||||||
|
st.current.active = false
|
||||||
|
stopAutoScroll()
|
||||||
|
setDragTask(null)
|
||||||
|
setOverCol(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => () => cleanup(), [cleanup])
|
||||||
|
|
||||||
|
const hitColumn = (x, y) => {
|
||||||
|
const el = document.elementFromPoint(x, y)
|
||||||
|
const colEl = el?.closest?.('[data-column-id]')
|
||||||
|
return colEl ? Number(colEl.dataset.columnId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 边缘自动滚动 (±36px 触发, 每帧 10px)
|
||||||
|
const autoScroll = (container, clientX) => {
|
||||||
|
if (!container) { stopAutoScroll(); return }
|
||||||
|
const r = container.getBoundingClientRect()
|
||||||
|
const edge = 36
|
||||||
|
let dir = 0
|
||||||
|
if (clientX < r.left + edge) dir = -1
|
||||||
|
else if (clientX > r.right - edge) dir = 1
|
||||||
|
st.current.autoScroll = dir
|
||||||
|
if (!dir) { stopAutoScroll(); return }
|
||||||
|
if (st.current.raf) return
|
||||||
|
const step = () => {
|
||||||
|
if (!st.current.autoScroll) return
|
||||||
|
container.scrollLeft += st.current.autoScroll * 10
|
||||||
|
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 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 col = hitColumn(ev.clientX, ev.clientY)
|
||||||
|
setOverCol(col)
|
||||||
|
autoScroll(document.querySelector(scrollSelector), ev.clientX)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPointerUp = (ev) => {
|
||||||
|
const s = st.current
|
||||||
|
if (s.active) {
|
||||||
|
const col = hitColumn(ev.clientX, ev.clientY)
|
||||||
|
if (col && s.task && col !== s.task.columnId) onDrop?.(s.task, col)
|
||||||
|
}
|
||||||
|
cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定到卡片: 需要卡片元素设置 touch-action: pan-y */
|
||||||
|
const bind = useCallback((task) => ({
|
||||||
|
onPointerDown: (ev) => {
|
||||||
|
// 捕获前先让事件正常冒泡(点击等); 只做长按计时
|
||||||
|
onPointerDown(task)(ev)
|
||||||
|
// 捕获: 后续 move/up 都派发给该元素, 不丢
|
||||||
|
try { ev.currentTarget.setPointerCapture(ev.pointerId) } catch { /* ignore */ }
|
||||||
|
const el = ev.currentTarget
|
||||||
|
const move = (e2) => onPointerMove(e2)
|
||||||
|
const up = (e2) => {
|
||||||
|
onPointerUp(e2)
|
||||||
|
el.removeEventListener('pointermove', move)
|
||||||
|
el.removeEventListener('pointerup', up)
|
||||||
|
el.removeEventListener('pointercancel', up)
|
||||||
|
}
|
||||||
|
el.addEventListener('pointermove', move)
|
||||||
|
el.addEventListener('pointerup', up)
|
||||||
|
el.addEventListener('pointercancel', up)
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
return { dragTask, overCol, bind, cancel: cleanup }
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
|
||||||
|
|
||||||
// 长按拖拽 (T-UI-07, T-ST-12)
|
|
||||||
// 用法: const drag = useLongPressDrag({ onDrop })
|
|
||||||
// - 长按 300ms 后进入拖拽态, 产生浮层数据
|
|
||||||
// - 松手时用 elementFromPoint 找落点: [data-column-id] / [data-task-id]
|
|
||||||
// - 返回 { dragging, ghost, bind(task) }
|
|
||||||
const LONG_PRESS = 300
|
|
||||||
const MOVE_TOLERANCE = 8
|
|
||||||
|
|
||||||
export default function useLongPressDrag({ onDropColumn, onDropTask, onReject } = {}) {
|
|
||||||
const [dragging, setDragging] = useState(null) // { task, x, y }
|
|
||||||
const timer = useRef(null)
|
|
||||||
const start = useRef(null)
|
|
||||||
const active = useRef(false)
|
|
||||||
|
|
||||||
const cleanup = useCallback(() => {
|
|
||||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
|
||||||
start.current = null
|
|
||||||
active.current = false
|
|
||||||
document.removeEventListener('touchmove', onMove, { passive: false })
|
|
||||||
document.removeEventListener('touchend', onEnd)
|
|
||||||
document.removeEventListener('mousemove', onMove)
|
|
||||||
document.removeEventListener('mouseup', onEnd)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const pointOf = (ev) => {
|
|
||||||
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
|
|
||||||
return { x: t.clientX, y: t.clientY }
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
function onMove(ev) {
|
|
||||||
if (!active.current) {
|
|
||||||
// 未进入拖拽前, 若移动超过容差则取消长按(用户想滚动)
|
|
||||||
if (start.current) {
|
|
||||||
const p = pointOf(ev)
|
|
||||||
if (Math.abs(p.x - start.current.x) > MOVE_TOLERANCE || Math.abs(p.y - start.current.y) > MOVE_TOLERANCE) {
|
|
||||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (ev.cancelable) ev.preventDefault()
|
|
||||||
const p = pointOf(ev)
|
|
||||||
setDragging((d) => (d ? { ...d, x: p.x, y: p.y } : d))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEnd(ev) {
|
|
||||||
if (!active.current) { cleanup(); return }
|
|
||||||
const p = pointOf(ev)
|
|
||||||
const el = document.elementFromPoint(p.x, p.y)
|
|
||||||
const taskEl = el?.closest?.('[data-task-id]')
|
|
||||||
const colEl = el?.closest?.('[data-column-id]')
|
|
||||||
const task = dragging?.task
|
|
||||||
cleanup()
|
|
||||||
setDragging(null)
|
|
||||||
if (!task) return
|
|
||||||
|
|
||||||
if (taskEl) {
|
|
||||||
const targetId = Number(taskEl.dataset.taskId)
|
|
||||||
if (targetId && targetId !== task.id) onDropTask?.(task, targetId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (colEl) {
|
|
||||||
const columnId = Number(colEl.dataset.columnId)
|
|
||||||
if (columnId && columnId !== task.columnId) onDropColumn?.(task, columnId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onReject?.('未放到有效位置')
|
|
||||||
}
|
|
||||||
|
|
||||||
const bind = useCallback((task) => {
|
|
||||||
const onStart = (ev) => {
|
|
||||||
if (ev.button && ev.button !== 0) return
|
|
||||||
const p = pointOf(ev)
|
|
||||||
start.current = p
|
|
||||||
setDragging(null)
|
|
||||||
active.current = false
|
|
||||||
timer.current = setTimeout(() => {
|
|
||||||
active.current = true
|
|
||||||
setDragging({ task, x: p.x, y: p.y })
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
onTouchStart: onStart,
|
|
||||||
onMouseDown: onStart,
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { dragging, bind, cancel: () => { cleanup(); setDragging(null) } }
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
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,35 @@
|
|||||||
|
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')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -1,78 +1,57 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Toast } from 'antd-mobile'
|
|
||||||
import TaskCard from '../../components/task/TaskCard.jsx'
|
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({
|
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 = useColumnDrag({ onDrop: onMoveColumn })
|
||||||
|
|
||||||
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 ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="board-wrap">
|
||||||
<div className="board-wrap">
|
{ordered.map((col) => {
|
||||||
{ordered.map((col) => {
|
const list = (tasks || []).filter((t) => t.columnId === col.id)
|
||||||
const list = (tasks || []).filter((t) => t.columnId === col.id)
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
className={`bcol${drag.overCol === col.id ? ' dragover' : ''}`}
|
||||||
className={`bcol${overCol === col.id ? ' dragover' : ''}`}
|
key={col.id}
|
||||||
key={col.id}
|
data-column-id={col.id}
|
||||||
data-column-id={col.id}
|
>
|
||||||
onDragEnter={() => setOverCol(col.id)}
|
<div className="bcol-head">
|
||||||
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
|
<span className="bcol-name">{col.name}</span>
|
||||||
>
|
<span className="bcol-cnt">{list.length}</span>
|
||||||
<div className="bcol-head">
|
{col.boundStatus ? (
|
||||||
<span className="bcol-name">{col.name}</span>
|
<span className="bcol-bind">
|
||||||
<span className="bcol-cnt">{list.length}</span>
|
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
|
||||||
{col.boundStatus ? (
|
</span>
|
||||||
<span className="bcol-bind">
|
) : (
|
||||||
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
|
<span className="bcol-bind off">未绑定</span>
|
||||||
</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>
|
</div>
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drag.dragging && (
|
<div className="bcol-body">
|
||||||
<div
|
{list.map((t) => (
|
||||||
className="drag-ghost"
|
<TaskCard
|
||||||
style={{ left: drag.dragging.x, top: drag.dragging.y }}
|
key={t.id}
|
||||||
>
|
task={t}
|
||||||
{drag.dragging.task.title}
|
dragging={drag.dragTask?.id === t.id}
|
||||||
</div>
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import TaskRow from '../../components/task/TaskRow.jsx'
|
|||||||
// 按看板列分组展示; 子任务缩进 + 折叠展开
|
// 按看板列分组展示; 子任务缩进 + 折叠展开
|
||||||
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口
|
// 子任务的添加只在任务详情页完成 (或把别的任务转为子任务), 列表页不放入口
|
||||||
export default function ListView({
|
export default function ListView({
|
||||||
tasks, columns, onToggle, onOpen, onMore,
|
tasks, columns, onToggle, onOpen, onMore, onLongPress,
|
||||||
}) {
|
}) {
|
||||||
const [collapsed, setCollapsed] = useState({})
|
const [collapsed, setCollapsed] = useState({})
|
||||||
|
|
||||||
@@ -48,11 +48,12 @@ export default function ListView({
|
|||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onClick={() => onOpen(t)}
|
onClick={() => onOpen(t)}
|
||||||
onMore={onMore}
|
onMore={onMore}
|
||||||
|
onLongPress={onLongPress}
|
||||||
/>
|
/>
|
||||||
{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} />
|
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} onLongPress={onLongPress} />
|
||||||
))}
|
))}
|
||||||
<div
|
<div
|
||||||
className="collapse"
|
className="collapse"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ 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'
|
||||||
@@ -24,8 +23,6 @@ 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 (
|
||||||
@@ -233,13 +230,7 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
visible={!!noteEdit}
|
visible={!!noteEdit}
|
||||||
onMaskClick={() => setNoteEdit(null)}
|
onMaskClick={() => setNoteEdit(null)}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
bodyStyle={{
|
className="kb-safe"
|
||||||
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>
|
||||||
@@ -258,6 +249,7 @@ 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 }))}
|
||||||
|
|||||||
@@ -114,17 +114,6 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
} catch { /* ignore */ }
|
} 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) {
|
async function createTask(title, parentId = null) {
|
||||||
const t = String(title || '').trim()
|
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) {
|
function openTaskMenu(task) {
|
||||||
ActionSheet.show({
|
ActionSheet.show({
|
||||||
extra: (
|
|
||||||
<div className="as-extra">
|
|
||||||
<div className="as-title">{task.title}</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
{ text: '编辑', key: 'edit' },
|
{ text: '编辑', key: 'edit' },
|
||||||
{ text: '拷贝到其他项目', key: 'copy' },
|
{ text: '拷贝到其他项目', key: 'copy' },
|
||||||
@@ -237,13 +236,13 @@ 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}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<BoardView
|
<BoardView
|
||||||
tasks={visibleTasks}
|
tasks={visibleTasks}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onMoveColumn={moveToColumn}
|
onMoveColumn={moveToColumn}
|
||||||
onMakeSubtask={makeSubtask}
|
|
||||||
onOpen={(t) => setOpenTask(t)}
|
onOpen={(t) => setOpenTask(t)}
|
||||||
onToggleSub={(c) => toggleDone(c)}
|
onToggleSub={(c) => toggleDone(c)}
|
||||||
onToggle={(t) => toggleDone(t)}
|
onToggle={(t) => toggleDone(t)}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
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 { 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)
|
||||||
@@ -25,11 +23,8 @@ 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)
|
||||||
|
|
||||||
@@ -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 (
|
return (
|
||||||
<Popup
|
<Popup
|
||||||
visible
|
visible
|
||||||
onMaskClick={onClose}
|
onMaskClick={onClose}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
bodyStyle={{
|
className="kb-safe"
|
||||||
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">
|
||||||
@@ -278,10 +250,6 @@ 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'}
|
||||||
@@ -301,22 +269,12 @@ 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)}
|
||||||
bodyStyle={{
|
className="kb-safe"
|
||||||
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
bodyStyle={{ maxHeight: '70vh' }}
|
||||||
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>
|
||||||
|
|||||||
@@ -1156,3 +1156,20 @@ 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) ----
|
||||||
|
底部弹层统一挂 .kb-safe: 底部留白 = 安全区 + 20px + 键盘插入量(--kb-inset,
|
||||||
|
由 App 级 useKeyboardInsetWatcher 写入)。键盘弹出时内容抬到键盘上方,
|
||||||
|
弹层自身 max-height 用 dvh 随视口收缩并内部滚动。 */
|
||||||
|
.kb-safe {
|
||||||
|
border-top-left-radius: 12px;
|
||||||
|
border-top-right-radius: 12px;
|
||||||
|
max-height: min(86dvh, calc(100dvh - 120px));
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + var(--kb-inset, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 拖动方案 (重点设计 §7.1) ----
|
||||||
|
卡片允许纵向滚动(PAN-Y), 水平位移留给拖动; 激活后原位抬起 */
|
||||||
|
.bcard { touch-action: pan-y; }
|
||||||
|
.bcard.dragging { opacity: .5; transform: scale(.97); box-shadow: 0 10px 22px rgba(15,23,42,.22); z-index: 6; }
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// 每次编译生成唯一 build 号 (YYYYMMDD.HHmmss), 通过 define 注入到 __BUILD_ID__
|
// 每次编译生成唯一 build 号 (YYYYMMDDHHmmss), 通过 define 注入到 __BUILD_ID__
|
||||||
// 版本号 APP_VERSION 唯一管理点在 package.json 的 version 字段 (手动修改)
|
// 版本号 APP_VERSION 唯一管理点在 package.json 的 version 字段 (手动修改)
|
||||||
function makeBuildId() {
|
function makeBuildId() {
|
||||||
// 统一用 UTC+8 (北京时间) 生成: CI runner 是 UTC 时区, 直接取本地时间会把日期/小时错位
|
// 统一用 UTC+8 (北京时间) 生成: CI runner 是 UTC 时区, 直接取本地时间会把日期/小时错位
|
||||||
const d = new Date(Date.now() + 8 * 3600 * 1000)
|
const d = new Date(Date.now() + 8 * 3600 * 1000)
|
||||||
const p = (n, w) => String(n).padStart(w || 2, '0')
|
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||||
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}.${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`
|
||||||
}
|
}
|
||||||
const BUILD_ID = makeBuildId()
|
const BUILD_ID = makeBuildId()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user