feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
Docker Build and Push / build-image (push) Failing after 3m51s
Docker Build and Push / build-image (push) Failing after 3m51s
需求 142 条 (T-*), 设计见 docs/V0.2/任务模块/ 数据层: - 新增 6 张表: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag - 看板列是实体(可增删改排序), Task.columnId 外键 RESTRICT 防误删列丢任务 - Task.status(todo/doing/done) 与 columnId 双落库, 二者正交 领域层 server/task-domain/ (唯一口径, 前后端不重复实现): - resolveStatus: 列决定状态(只看目标列, 无拖出回退); 状态与列不匹配时按列序取第一个绑定该状态的列自动归位, 无匹配列则不移动; 子任务完全豁免 - retention: 逐列 6 档保留窗口(永不显示/当天/3天/本周/本月/永久显示), 以 doneAt 判定 - copyTask: 跨项目深拷贝, 人员按目标项目成员过滤并回报丢弃名单 接口: /api/projects, /api/columns, /api/tasks (均校验项目成员) 前端: - 底部 Tab 插入「任务」, 位于「打卡」之后; 默认首页仍为打卡 - 列表视图(子任务缩进+折叠) / 看板视图(长按拖拽换列、拖到卡片降为子任务) - 列设置(列管理+状态绑定+保留选项) / 任务详情(扩展信息卡片默认收起) / 成员管理 - 视图记忆与上次项目存设备端 已验证: 95 文件语法 0 错误、相对导入 0 缺失、领域逻辑用例全部通过
This commit is contained in:
@@ -5,8 +5,10 @@ import TabIcon from './TabIcon.jsx'
|
||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
||||
// V0.2 任务模块: 「任务」插入到「打卡」之后 (T-PM-06), 默认首页仍为打卡
|
||||
const TABS = [
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/task', name: 'task', label: '任务' },
|
||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||
{ key: '/me', name: 'me', label: '我' },
|
||||
|
||||
@@ -25,6 +25,16 @@ const ICONS = {
|
||||
<path d="M9 15.5l2.2 2.2L15.5 13" />
|
||||
</svg>
|
||||
),
|
||||
// 任务: 清单本 + 勾选行 (V0.2 任务模块)
|
||||
task: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="4" width="16" height="17" rx="2" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
|
||||
<path d="M8 9.5l1.4 1.4L12 8.3" />
|
||||
<path d="M14.5 9.5h3" />
|
||||
<path d="M8 15.5l1.4 1.4L12 14.3" />
|
||||
<path d="M14.5 15.5h3" />
|
||||
</svg>
|
||||
),
|
||||
me: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// 扩展信息卡片 (T-TK-04~07): 任意条目, 默认收起, 全部非必填
|
||||
// 条目结构 { label, value, url? }
|
||||
export default function ExtrasCard({ extras = [], onChange, readOnly = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const list = Array.isArray(extras) ? extras : []
|
||||
|
||||
const update = (idx, key, val) => {
|
||||
const next = list.map((e, i) => (i === idx ? { ...e, [key]: val } : e))
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const add = () => onChange?.([...list, { label: '', value: '', url: '' }])
|
||||
const remove = (idx) => onChange?.(list.filter((_, i) => i !== idx))
|
||||
|
||||
return (
|
||||
<div className="extras">
|
||||
<div className="extras-head" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="caret">{open ? '▾' : '▸'}</span>
|
||||
<span>扩展信息</span>
|
||||
{list.length > 0 && <span className="tag">{list.length}</span>}
|
||||
<span className="cv">{open ? '收起' : '默认收起 · 全部非必填'}</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="extras-body">
|
||||
{list.length === 0 && (
|
||||
<div className="extras-empty">暂无条目,可记录仓库 / 分支 / 链接等任意信息</div>
|
||||
)}
|
||||
{list.map((e, i) => (
|
||||
<div className="extra-row" key={i}>
|
||||
<input
|
||||
className="inp extra-k"
|
||||
placeholder="名称"
|
||||
value={e.label || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(ev) => update(i, 'label', ev.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="inp extra-v"
|
||||
placeholder="值"
|
||||
value={e.value || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(ev) => update(i, 'value', ev.target.value)}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" className="extra-del" onClick={() => remove(i)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button type="button" className="extras-add" onClick={add}>+ 添加一条</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 任务状态小圆点 / 药丸标记 (T-ST-09)
|
||||
const LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
|
||||
|
||||
export default function StatusDot({ status = 'todo', pill = false, className = '' }) {
|
||||
const cls = `sdot ${status}${pill ? ' pill' : ''} ${className}`.trim()
|
||||
if (pill) return <span className={cls}>{LABEL[status] || ''}</span>
|
||||
return <span className={cls} title={LABEL[status] || ''} />
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import StatusDot from './StatusDot.jsx'
|
||||
|
||||
// 看板卡片 (T-UI-02): 轻量化
|
||||
// - 标题 + 状态点 (T-ST-09)
|
||||
// - 负责人 12px/600 突出 (T-AS-01); 任务成员 10.5px 弱化置于下方 (T-AS-02)
|
||||
// - 子任务内嵌简洁展示 + 可直接勾选 (T-ST-06/07)
|
||||
const MAX_SUB = 3
|
||||
|
||||
export default function TaskCard({
|
||||
task, bindDrag, onClick, onToggleSub, onToggle, onAddSub, dragging = false, highlight = false,
|
||||
}) {
|
||||
const children = task.children || []
|
||||
const shown = children.slice(0, MAX_SUB)
|
||||
const rest = children.length - shown.length
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bcard${dragging ? ' dragging' : ''}${highlight ? ' hl' : ''}`}
|
||||
data-task-id={task.id}
|
||||
onClick={onClick}
|
||||
{...(bindDrag ? bindDrag(task) : {})}
|
||||
>
|
||||
<div className="brow1">
|
||||
<span className="btitle">{task.title}</span>
|
||||
<span
|
||||
className="bstat"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||
title="点按切换完成"
|
||||
>
|
||||
<StatusDot status={task.status} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bmeta">
|
||||
<span className={`bassignee${task.assignee ? '' : ' none'}`}>
|
||||
{task.assignee?.name || '未指派'}
|
||||
</span>
|
||||
{task.members?.length > 0 && (
|
||||
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shown.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||
>
|
||||
<span className="sbox" />
|
||||
<span className="stitle">{c.title}</span>
|
||||
<StatusDot status={c.status} />
|
||||
</div>
|
||||
))}
|
||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||
|
||||
{onAddSub && (
|
||||
<div
|
||||
className="bsub-add"
|
||||
onClick={(e) => { e.stopPropagation(); onAddSub(task) }}
|
||||
>
|
||||
+ 子任务
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import StatusDot from './StatusDot.jsx'
|
||||
|
||||
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
||||
export default function TaskRow({
|
||||
task, onToggle, onClick, onMore, indent = false, children,
|
||||
}) {
|
||||
const done = task.status === 'done'
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
||||
data-task-id={indent ? undefined : task.id}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className="box"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{indent ? (
|
||||
<>
|
||||
<span className="sub-title">{task.title}</span>
|
||||
<StatusDot status={task.status} pill />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
<StatusDot status={task.status} />
|
||||
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span>
|
||||
{task.column?.name && <span className="col">{task.column.name}</span>}
|
||||
<span className={`assignee${task.assignee ? '' : ' none'}`}>
|
||||
{task.assignee?.name || '未指派'}
|
||||
</span>
|
||||
{task.members?.length > 0 && (
|
||||
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-right">
|
||||
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Popup, Toast } from 'antd-mobile'
|
||||
|
||||
// 人员选择 (T-AS-03/04): 只能从"当前项目成员"中选
|
||||
export default function UserPicker({
|
||||
visible, members = [], value, multiple = false, title = '选择负责人',
|
||||
allowEmpty = true, onClose, onConfirm,
|
||||
}) {
|
||||
const [sel, setSel] = useState(multiple ? [] : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
setSel(multiple ? (value || []) : (value ?? null))
|
||||
}, [visible, value, multiple])
|
||||
|
||||
const toggle = (uid) => {
|
||||
if (multiple) {
|
||||
setSel((prev) => {
|
||||
const arr = Array.isArray(prev) ? prev : []
|
||||
return arr.includes(uid) ? arr.filter((x) => x !== uid) : [...arr, uid]
|
||||
})
|
||||
} else {
|
||||
setSel((prev) => (prev === uid ? null : uid))
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
if (!multiple && sel === null && !allowEmpty) {
|
||||
Toast.show({ content: '请选择负责人' })
|
||||
return
|
||||
}
|
||||
onConfirm?.(sel)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}>
|
||||
<div className="sheet-title">{title}</div>
|
||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||
|
||||
{members.length === 0 && <div className="extras-empty">项目暂无成员</div>}
|
||||
|
||||
{members.map((m) => {
|
||||
const on = multiple ? (Array.isArray(sel) && sel.includes(m.userId)) : sel === m.userId
|
||||
return (
|
||||
<div className="pick-row" key={m.userId} onClick={() => toggle(m.userId)}>
|
||||
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
|
||||
<span className="pn">{m.name}</span>
|
||||
<span className={`ck${on ? ' on' : ''}`} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{allowEmpty && !multiple && (
|
||||
<div className="pick-row" onClick={() => setSel(null)}>
|
||||
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
|
||||
<span className="pn">清除负责人</span>
|
||||
<span className={`ck${sel === null ? ' on' : ''}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btn-primary" onClick={confirm}>确定</div>
|
||||
<div className="btn-ghost" onClick={onClose}>取消</div>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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) } }
|
||||
}
|
||||
Reference in New Issue
Block a user