feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
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:
jif2.zhang
2026-10-24 11:21:16 +08:00
parent 42acf72f1e
commit 6c9cd3fd99
36 changed files with 6194 additions and 0 deletions
+2
View File
@@ -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: '我' },
+10
View File
@@ -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} />
+59
View File
@@ -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>
)
}
+8
View File
@@ -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] || ''} />
}
+66
View File
@@ -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>
)
}
+54
View File
@@ -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}
</>
)
}
+66
View File
@@ -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>
)
}
+99
View File
@@ -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) } }
}