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
+77
View File
@@ -0,0 +1,77 @@
import { useState } from 'react'
import { Toast } from 'antd-mobile'
import TaskCard from '../../components/task/TaskCard.jsx'
import useLongPressDrag from '../../components/task/useLongPressDrag.js'
// 看板视图 (T-BD-01, T-UI-07, T-ST-06/12)
// 列横向滚动; 长按卡片拖拽换列 / 降为子任务
export default function BoardView({
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onQuickAdd,
}) {
const [overCol, setOverCol] = useState(null)
const drag = useLongPressDrag({
onDropColumn: (task, columnId) => { setOverCol(null); onMoveColumn?.(task, columnId) },
onDropTask: (task, parentId) => { setOverCol(null); onMakeSubtask?.(task, parentId) },
onReject: (msg) => { setOverCol(null); Toast.show({ content: msg }) },
})
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
return (
<>
<div className="board-wrap">
{ordered.map((col) => {
const list = (tasks || []).filter((t) => t.columnId === col.id)
return (
<div
className={`bcol${overCol === col.id ? ' dragover' : ''}`}
key={col.id}
data-column-id={col.id}
onDragEnter={() => setOverCol(col.id)}
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
>
<div className="bcol-head">
<span className="bcol-name">{col.name}</span>
<span className="bcol-cnt">{list.length}</span>
{col.boundStatus ? (
<span className="bcol-bind">
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
</span>
) : (
<span className="bcol-bind off">未绑定</span>
)}
</div>
<div className="bcol-body">
{list.map((t) => (
<TaskCard
key={t.id}
task={t}
dragging={drag.dragging?.task?.id === t.id}
highlight={drag.dragging && drag.dragging.task.id !== t.id && overCol === col.id}
bindDrag={drag.bind}
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
onToggleSub={onToggleSub}
onToggle={onToggle}
/>
))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
</div>
</div>
)
})}
</div>
{drag.dragging && (
<div
className="drag-ghost"
style={{ left: drag.dragging.x, top: drag.dragging.y }}
>
{drag.dragging.task.title}
</div>
)}
</>
)
}
+194
View File
@@ -0,0 +1,194 @@
import { useState } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { columnApi } from '../../api/task.js'
import { RETENTION_OPTIONS, RETENTION_LABEL, STATUS_OPTIONS, STATUS_LABEL } from './date.js'
const MAX_COLUMNS = 10
// 列设置 (T-COL-*, T-BD-02/09/10, T-FL-01/02/15)
// 1) 列管理: 改名 / 新增 / 删除 / 排序
// 2) 状态绑定: 每列独立选择 不绑定 / 未开始 / 执行中 / 完成
// 3) 保留选项: 每列独立 6 档
export default function ColumnSettings({ projectId, columns, onClose, onSaved }) {
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
const [list, setList] = useState(ordered)
const [editingName, setEditingName] = useState(null) // { id, name }
const [pending, setPending] = useState(false)
const patchLocal = (id, patch) =>
setList((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)))
async function save() {
setPending(true)
try {
// 逐列保存变更(只提交与初始不同的字段)
for (const c of list) {
const orig = ordered.find((o) => o.id === c.id)
if (!orig) continue
const body = {}
if (orig.name !== c.name) body.name = c.name
if (orig.boundStatus !== c.boundStatus) body.boundStatus = c.boundStatus
if (orig.retention !== c.retention) body.retention = c.retention
if (Object.keys(body).length) await columnApi.update(c.id, body)
}
Toast.show({ content: '已保存' })
onSaved?.()
} catch (e) {
Toast.show({ content: e.message })
} finally {
setPending(false)
}
}
async function addColumn() {
if (list.length >= MAX_COLUMNS) { Toast.show({ content: `最多 ${MAX_COLUMNS} 列` }); return }
const name = `新列 ${list.length + 1}`
try {
const r = await columnApi.add(projectId, { name })
setList((prev) => [...prev, r.item])
} catch (e) {
Toast.show({ content: e.message })
}
}
async function removeColumn(col) {
if (list.length <= 1) { Toast.show({ content: '至少保留一列' }); return }
const idx = list.findIndex((c) => c.id === col.id)
const fallback = list[idx + 1] || list[idx - 1]
const ok = await Dialog.confirm({
content: `删除「${col.name}」?其中的任务将迁移到「${fallback.name}」,其状态会按目标列的绑定重新确定。`,
confirmText: '删除',
})
if (!ok) return
try {
await columnApi.remove(col.id, fallback.id)
setList((prev) => prev.filter((c) => c.id !== col.id))
Toast.show({ content: '已删除' })
} catch (e) {
Toast.show({ content: e.message })
}
}
function move(idx, dir) {
const next = [...list]
const j = idx + dir
if (j < 0 || j >= next.length) return
;[next[idx], next[j]] = [next[j], next[idx]]
setList(next)
// 立即持久化顺序
columnApi.order(projectId, next.map((c) => c.id)).catch((e) => Toast.show({ content: e.message }))
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">列设置</span>
<div className="right">
<span className="mini-btn solid" onClick={save}>{pending ? '保存中…' : '保存'}</span>
</div>
</div>
<div className="cs-body">
<div className="set-hint">
调整列顺序会影响「改状态时自动归位」的目标列(取第一个绑定该状态的列)。
</div>
<div className="section-h" style={{ margin: '12px 14px 8px' }}>列与状态绑定</div>
{list.map((col, idx) => (
<div className="set-row" key={col.id}>
<span className="cs-ord">
<span onClick={() => move(idx, -1)}>↑</span>
<span onClick={() => move(idx, 1)}>↓</span>
</span>
<span
className="sname"
onClick={() => setEditingName({ id: col.id, name: col.name })}
>
{col.name}
</span>
<div className="sctrl">
<select
className={`drop${col.boundStatus ? '' : ' off'}`}
value={col.boundStatus || ''}
onChange={(e) => patchLocal(col.id, { boundStatus: e.target.value || null })}
>
<option value="">不绑定状态</option>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
<span className="cs-del" onClick={() => removeColumn(col)}>删</span>
</div>
</div>
))}
<div style={{ padding: '12px 14px' }}>
<span className="mini-btn" onClick={addColumn}>+ 新增列</span>
<span className="cs-count">当前 {list.length} / {MAX_COLUMNS} 列</span>
</div>
<div className="section-h" style={{ margin: '6px 14px 8px' }}>已完成任务保留</div>
{list.map((col) => (
<div className="set-row" key={col.id}>
<span className="sname" style={{ width: 82 }}>{col.name}</span>
<div className="sctrl">
<select
className="drop"
value={col.retention || 'forever'}
onChange={(e) => patchLocal(col.id, { retention: e.target.value })}
>
{RETENTION_OPTIONS.map((o) => (
<option key={o} value={o}>{RETENTION_LABEL[o]}</option>
))}
</select>
</div>
</div>
))}
<div className="set-hint">
保留选项:永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示。<br />
仅影响已完成任务;未完成任务始终展示。列表与看板按同一规则过滤。
</div>
<div style={{ padding: '0 14px 20px' }}>
<div className="btn-primary" onClick={save}>{pending ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={onClose}>取消</div>
</div>
</div>
<Dialog
visible={!!editingName}
title="列名"
content={
<input
className="inp"
value={editingName?.name || ''}
maxLength={20}
onChange={(e) => setEditingName((p) => ({ ...p, name: e.target.value }))}
/>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setEditingName(null) },
{
key: 'ok',
text: '确定',
bold: true,
onClick: () => {
const name = (editingName?.name || '').trim()
if (!name) { Toast.show({ content: '列名不能为空' }); return }
if (list.some((c) => c.id !== editingName.id && c.name === name)) {
Toast.show({ content: '列名已存在' }); return
}
patchLocal(editingName.id, { name })
setEditingName(null)
},
},
]}
onClose={() => setEditingName(null)}
/>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
import { useState } from 'react'
import TaskRow from '../../components/task/TaskRow.jsx'
// 列表视图 (T-VW-01, T-ST-04/05)
// 按看板列分组展示(列表不分组也允许, 这里默认分组更接近 Todoist)
// 子任务缩进 + 折叠展开
export default function ListView({
tasks, columns, onToggle, onOpen, onAddSub,
}) {
const [collapsed, setCollapsed] = useState({})
if (!tasks || tasks.length === 0) {
return (
<div className="empty" style={{ paddingTop: 90 }}>
<div className="ico">✓</div>
<p>这个项目还没有任务</p>
</div>
)
}
// 分组: 按列顺序, 未匹配列的任务归到一起
const groups = []
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
for (const c of orderedCols) {
const list = tasks.filter((t) => t.columnId === c.id)
if (list.length) groups.push({ key: c.id, name: c.name, list })
}
const known = new Set(orderedCols.map((c) => c.id))
const orphans = tasks.filter((t) => !known.has(t.columnId))
if (orphans.length) groups.push({ key: 'other', name: '其他', list: orphans })
return (
<div className="list-body">
{groups.map((g) => (
<div key={g.key}>
<div className="group-title">
{g.name}
<span className="cnt">{g.list.length}</span>
</div>
{g.list.map((t) => {
const kids = t.children || []
const isCollapsed = !!collapsed[t.id]
return (
<div key={t.id}>
<TaskRow
task={t}
onToggle={onToggle}
onClick={() => onOpen(t)}
onMore={() => onOpen(t)}
/>
{kids.length > 0 && (
<>
{!isCollapsed && kids.map((c) => (
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} />
))}
<div
className="collapse"
onClick={() => setCollapsed((p) => ({ ...p, [t.id]: !p[t.id] }))}
>
{isCollapsed ? `展开 ${kids.length} 个子任务 ⌄` : `收起 ${kids.length} 个子任务 ⌃`}
</div>
</>
)}
{kids.length === 0 && onAddSub && (
<div className="sub-add-hint" onClick={() => onAddSub(t)}>+ 子任务</div>
)}
</div>
)
})}
</div>
))}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
// 项目成员 (T-PJ-06/07, T-AS-06, T-PM-02)
export default function ProjectMembers({ project, onClose, onChanged }) {
const { user } = useAuth()
const meId = user?.id
const [members, setMembers] = useState(project.members || [])
const [adding, setAdding] = useState(false)
const [email, setEmail] = useState('')
async function add() {
const e = email.trim()
if (!e) return
try {
await projectApi.addMember(project.id, { email: e })
setEmail('')
setAdding(false)
Toast.show({ content: '已添加' })
onChanged?.()
onClose?.()
} catch (err) {
Toast.show({ content: err.message })
}
}
async function remove(m, isSelf) {
const ok = await Dialog.confirm({
content: isSelf ? '退出该项目?退出后将不再可见。' : `移除成员「${m.name}」?`,
confirmText: isSelf ? '退出' : '移除',
})
if (!ok) return
try {
await projectApi.removeMember(project.id, m.userId)
setMembers((prev) => prev.filter((x) => x.userId !== m.userId))
Toast.show({ content: isSelf ? '已退出' : '已移除' })
onChanged?.()
if (isSelf) onClose?.()
} catch (err) {
Toast.show({ content: err.message })
}
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">{project.name}</span>
<div className="right">
{!project.isPersonal && (
<span className="mini-btn solid" onClick={() => setAdding(true)}>+ 添加</span>
)}
</div>
</div>
<div className="cs-body">
<div className="set-hint">
{project.isPersonal
? '个人默认项目不可添加成员。'
: '项目内所有成员权限均等、全读写;负责人与成员只是信息标记,不影响权限。'}
</div>
{members.map((m) => {
const isOwner = m.userId === project.ownerId
const isMe = m.userId === meId
return (
<div className="set-row" key={m.userId} style={{ padding: '12px 14px' }}>
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
{m.name}
{isMe && <span className="tag grey" style={{ marginLeft: 6 }}>我</span>}
{isOwner && <span className="tag grey" style={{ marginLeft: 6 }}>创建人</span>}
</span>
{isMe && !project.isPersonal && (
<span className="link danger" onClick={() => remove(m, true)}>退出</span>
)}
{!isMe && isOwner && <span className="link" style={{ color: '#999' }}>—</span>}
{!isMe && !isOwner && !project.isPersonal && (
<span className="link danger" onClick={() => remove(m, false)}>移除</span>
)}
</div>
)
})}
<div className="set-hint">
成员被移出项目后,其担任的负责人 / 成员标记会保留历史并显示为「已退出」,不清空、不报错。
</div>
</div>
<Dialog
visible={adding}
title="添加成员"
content={
<input
className="inp"
placeholder="输入成员邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setAdding(false) },
{ key: 'ok', text: '添加', bold: true, onClick: add },
]}
onClose={() => setAdding(false)}
/>
</div>
)
}
+255
View File
@@ -0,0 +1,255 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { filterVisible, msToNextBoundary } from './date.js'
import { getViewMode, setViewMode } from './prefs.js'
import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx'
import ColumnSettings from './ColumnSettings.jsx'
import TaskDetail from './TaskDetail.jsx'
import ProjectMembers from './ProjectMembers.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
export default function ProjectView({ projectId, onBack }) {
const [project, setProject] = useState(null)
const [tasks, setTasks] = useState(null)
const [columns, setColumns] = useState([])
const [err, setErr] = useState('')
const [mode, setMode] = useState(() => getViewMode(projectId) || 'list')
const [openTask, setOpenTask] = useState(null)
const [showColumns, setShowColumns] = useState(false)
const [showMembers, setShowMembers] = useState(false)
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const load = useCallback(() => {
setErr('')
Promise.all([projectApi.detail(projectId), taskApi.list(projectId)])
.then(([p, t]) => {
setProject(p.item)
setColumns(p.item.columns || [])
setTasks(t.items || [])
})
.catch((e) => setErr(e.message))
}, [projectId])
useEffect(() => { setTasks(null); load() }, [load])
// 切换视图并记忆 (T-VW-02/03/06)
const switchMode = (next) => {
setMode(next)
setViewMode(projectId, next)
}
// 保留窗口到点重算 (T-EX-18)
useEffect(() => {
const options = (columns || []).map((c) => c.retention)
const near = options
.map((o) => msToNextBoundary(o))
.filter((v) => v != null)
.sort((a, b) => a - b)[0]
const t = setTimeout(() => setTick((v) => v + 1), near ? Math.min(near, 60_000) : 60_000)
return () => clearTimeout(t)
}, [columns, tick])
const visibleTasks = useMemo(
() => filterVisible(tasks || [], columns),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tasks, columns, tick],
)
// ---- 乐观更新 ----
const patchTask = (id, patch) => setTasks((prev) => (prev || []).map((t) => {
if (t.id === id) return { ...t, ...patch }
const kids = (t.children || []).map((c) => (c.id === id ? { ...c, ...patch } : c))
return { ...t, children: kids }
}))
const replaceTask = (fresh) => setTasks((prev) => (prev || []).map((t) => (t.id === fresh.id ? fresh : t)))
/** 更新任务(列/状态/字段), 服务端裁决后以返回值为准 */
async function updateTask(id, patch) {
const snapshot = tasks
patchTask(id, patch)
try {
const r = await taskApi.update(id, patch)
replaceTask(r.item)
return r.item
} catch (e) {
setTasks(snapshot)
Toast.show({ content: e.message })
throw e
}
}
/** 勾选完成 / 取消完成 */
const toggleDone = (task) => updateTask(task.id, {
status: task.status === 'done' ? 'todo' : 'done',
})
/** 拖拽换列 (T-BD-04/07) */
const moveToColumn = async (task, columnId) => {
const col = columns.find((c) => c.id === columnId)
try {
await updateTask(task.id, { columnId })
const target = columns.find((c) => c.id === columnId)
if (target?.boundStatus && target.boundStatus !== task.status) {
Toast.show({ content: `已移入 ${col?.name},状态改为「${target.boundStatus === 'done' ? '完成' : target.boundStatus === 'doing' ? '执行中' : '未开始'}」` })
} else {
Toast.show({ content: `已移入 ${col?.name}` })
}
} catch { /* 已回滚并提示 */ }
}
/** 改状态 (T-BD-14~16) */
const changeStatus = async (task, status) => {
try {
const fresh = await updateTask(task.id, { status })
const moved = fresh && fresh.columnId !== task.columnId
if (moved) {
const col = columns.find((c) => c.id === fresh.columnId)
Toast.show({ content: `已移至 ${col?.name || '目标列'}` })
}
} catch { /* ignore */ }
}
/** 拖拽降为子任务 (T-ST-12) */
const makeSubtask = async (task, parentId) => {
try {
await updateTask(task.id, { parentId })
// 成为子任务后不再作为顶层卡片出现
setTasks((prev) => (prev || []).filter((t) => t.id !== task.id))
Toast.show({ content: '已转为子任务' })
load()
} catch { /* 错误已提示 */ }
}
/** 新建任务 */
async function createTask(title, parentId = null) {
const t = String(title || '').trim()
if (!t) return
try {
await taskApi.create(projectId, { title: t, parentId })
setAdding(false)
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
if (err) {
return (
<div className="page-with-navbar">
<div className="navbar-sub">
<span className="back" onClick={onBack}>‹</span>
<span className="title">任务</span>
</div>
<div className="empty">
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div>
</div>
</div>
)
}
if (!project || !tasks) {
return (
<div className="page-with-navbar">
<div className="navbar-sub">
<span className="back" onClick={onBack}>‹</span>
<span className="title">任务</span>
</div>
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
</div>
)
}
return (
<div className="page-with-navbar">
<div className="navbar-sub">
<span className="back" onClick={onBack}>‹</span>
<span className="title">{project.name}</span>
<div className="right">
<span className="mini-btn" onClick={() => setShowMembers(true)}>成员</span>
<span className="mini-btn" onClick={() => setAdding((v) => !v)}>+ 新增</span>
</div>
</div>
<div className={`task-content${mode === 'board' ? ' is-board' : ''}`}>
<div className="pv-head">
<div className="seg">
<div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div>
<div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div>
</div>
<div className="right">
<span className="mini-btn grey" onClick={() => setShowColumns(true)}>列设置</span>
</div>
</div>
{adding && (
<div className="quick-add">
<input
className="inp"
autoFocus
placeholder="输入任务标题,回车创建"
onKeyDown={(e) => {
if (e.key === 'Enter') createTask(e.currentTarget.value)
if (e.key === 'Escape') setAdding(false)
}}
/>
</div>
)}
{mode === 'list' ? (
<ListView
tasks={visibleTasks}
columns={columns}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onAddSub={(parent) => setOpenTask(parent)}
/>
) : (
<BoardView
tasks={visibleTasks}
columns={columns}
onMoveColumn={moveToColumn}
onMakeSubtask={makeSubtask}
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
onQuickAdd={() => { setAdding(true) }}
/>
)}
</div>
{openTask && (
<TaskDetail
task={openTask}
columns={columns}
members={project.members || []}
onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }}
onChanged={load}
/>
)}
{showColumns && (
<ColumnSettings
projectId={projectId}
columns={columns}
onClose={() => setShowColumns(false)}
onSaved={() => { setShowColumns(false); load() }}
/>
)}
{showMembers && (
<ProjectMembers
project={project}
onClose={() => setShowMembers(false)}
onChanged={load}
/>
)}
</div>
)
}
+272
View File
@@ -0,0 +1,272 @@
import { useEffect, useState } from 'react'
import { Dialog, Popup, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
import UserPicker from '../../components/task/UserPicker.jsx'
import StatusDot from '../../components/task/StatusDot.jsx'
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
const [form, setForm] = useState(() => ({
title: task.title || '',
description: task.description || '',
extras: task.extras || [],
memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null,
status: task.status,
tags: task.tags || [],
}))
const [subs, setSubs] = useState(task.children || [])
const [newSub, setNewSub] = useState('')
const [saving, setSaving] = useState(false)
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
useEffect(() => {
setForm({
title: task.title || '',
description: task.description || '',
extras: task.extras || [],
memberIds: (task.members || []).map((m) => m.userId),
assigneeId: task.assignee?.id ?? null,
status: task.status,
tags: task.tags || [],
})
setSubs(task.children || [])
}, [task])
async function save() {
if (!form.title.trim()) { Toast.show({ content: '标题不能为空' }); return }
setSaving(true)
try {
const r = await taskApi.update(task.id, {
title: form.title.trim(),
description: form.description,
extras: form.extras,
assigneeId: form.assigneeId,
memberIds: form.memberIds,
status: form.status,
tags: form.tags,
})
Toast.show({ content: '已保存' })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
} finally {
setSaving(false)
}
}
async function addSub() {
const t = newSub.trim()
if (!t) return
try {
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
setSubs((prev) => [...prev, r.item])
setNewSub('')
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function toggleSub(sub) {
const next = sub.status === 'done' ? 'todo' : 'done'
try {
const r = await taskApi.update(sub.id, { status: next })
setSubs((prev) => prev.map((s) => (s.id === sub.id ? r.item : s)))
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
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 })
}
}
async function openCopy() {
try {
const d = await projectApi.list()
setCopies((d.items || []).filter((p) => p.id !== task.projectId && !p.archived))
} catch (e) {
Toast.show({ content: e.message })
}
}
async function doCopy(projectId) {
try {
const r = await taskApi.copy(task.id, projectId)
const dropped = r.dropped || {}
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
Toast.show({
content: lost.length
? `已拷贝到目标项目;${lost.join('、')} 不是该项目成员,已不保留`
: '已拷贝到目标项目',
})
setCopies(null)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<Popup
visible
onMaskClick={onClose}
position="bottom"
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px 14px' }}
>
<div className="sheet-title">{task.title}</div>
<div className="sheet-sub">
{task.column?.name} · {STATUS_LABEL[task.status]}
</div>
<div className="field">
<label>标题</label>
<input
className="inp"
value={form.title}
maxLength={200}
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
/>
</div>
<div className="field">
<label>描述</label>
<textarea
className="inp"
rows={2}
placeholder="补充说明…"
value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
/>
</div>
<div className="field">
<label>状态</label>
<div className="seg">
{STATUS_OPTIONS.map((s) => (
<div
key={s}
className={form.status === s ? 'on' : ''}
onClick={() => setForm((p) => ({ ...p, status: s }))}
>
{STATUS_LABEL[s]}
</div>
))}
</div>
</div>
<div className="field">
<label>负责人</label>
<div className="pick-row" onClick={() => setPicker('assignee')}>
{assigneeName ? (
<div className="avatar">{assigneeName.slice(0, 1)}</div>
) : (
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
)}
<span className="pn">{assigneeName || '未指派'}</span>
<span className="link">更换</span>
</div>
</div>
<div className="field">
<label>任务成员</label>
<div className="pick-row" onClick={() => setPicker('members')}>
<div className="avatar" style={{ background: '#eef2f7', color: '#7b8794' }}>
{form.memberIds.length || 0}
</div>
<span className="pn" style={{ fontSize: 12.5, color: form.memberIds.length ? 'inherit' : '#999' }}>
{memberNames.length ? memberNames.join('、') : '未选择成员'}
</span>
<span className="link">编辑</span>
</div>
</div>
<ExtrasCard
extras={form.extras}
onChange={(next) => setForm((p) => ({ ...p, extras: next }))}
/>
<div className="section-h">子任务 <span className="tag">{subs.length}</span></div>
{subs.map((s) => (
<div className="pick-row" key={s.id} onClick={() => toggleSub(s)}>
<span className={`ck${s.status === 'done' ? ' on' : ''}`} />
<span className="pn">{s.title}</span>
<StatusDot status={s.status} />
</div>
))}
<div className="extra-row" style={{ marginTop: 8 }}>
<input
className="inp"
placeholder="添加子任务,回车确认"
value={newSub}
maxLength={200}
onChange={(e) => setNewSub(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
/>
</div>
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={openCopy}>拷贝到其他项目</div>
<div className="btn-danger" onClick={removeTask}>
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
</div>
<UserPicker
visible={picker === 'assignee'}
title="选择负责人"
members={members}
value={form.assigneeId}
onClose={() => setPicker(null)}
onConfirm={(uid) => setForm((p) => ({ ...p, assigneeId: uid }))}
/>
<UserPicker
visible={picker === 'members'}
title="选择任务成员"
multiple
members={members}
value={form.memberIds}
onClose={() => setPicker(null)}
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
/>
<Popup
visible={!!copies}
onMaskClick={() => setCopies(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}
>
<div className="sheet-title">跨项目拷贝</div>
<div className="sheet-sub">源任务保持不变;新任务的创建人是你</div>
{(copies || []).length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
{(copies || []).map((p) => (
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id)}>
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
<span className="pn">{p.name}</span>
<span className="link">拷贝</span>
</div>
))}
<div className="btn-ghost" onClick={() => setCopies(null)}>取消</div>
</Popup>
</Popup>
)
}
+177
View File
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Dialog, DotLoading, Empty, Input, Toast } from 'antd-mobile'
import PageHeader from '../../components/PageHeader.jsx'
import { projectApi } from '../../api/task.js'
import { getLastProjectId, setLastProjectId, prunePrefs } from './prefs.js'
import ProjectView from './ProjectView.jsx'
// 任务模块容器 (T-PM-06)
// 无 :pid -> 项目列表
// 有 :pid -> 该项目视图(列表/看板)
export default function TaskPage() {
const { pid } = useParams()
const navigate = useNavigate()
const [projects, setProjects] = useState(null)
const [err, setErr] = useState('')
const load = useCallback(() => {
setErr('')
projectApi.list()
.then((d) => {
const items = d.items || []
setProjects(items)
prunePrefs(items.map((p) => p.id))
})
.catch((e) => setErr(e.message))
}, [])
useEffect(() => { load() }, [load])
// 记住上次访问的项目 (T-VW-07)
useEffect(() => {
if (pid) setLastProjectId(Number(pid))
}, [pid])
if (pid) {
return <ProjectView projectId={Number(pid)} onBack={() => navigate('/task')} />
}
return (
<div className="page-with-navbar">
<PageHeader title="任务" />
<div className="task-content">
<ProjectList
projects={projects}
err={err}
onRetry={load}
onOpen={(id) => {
setLastProjectId(id)
navigate(`/task/${id}`)
}}
onChanged={load}
/>
</div>
</div>
)
}
function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
if (err) {
return (
<div className="empty">
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={onRetry}>重试</div>
</div>
)
}
if (!projects) {
return (
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
)
}
const active = projects.filter((p) => !p.archived)
const archived = projects.filter((p) => p.archived)
async function create() {
const t = name.trim()
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
setSaving(true)
try {
await projectApi.create({ name: t })
setName('')
setCreating(false)
Toast.show({ content: '已创建' })
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
} finally {
setSaving(false)
}
}
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
return (
<>
<div className="proj-head">
<b>我的项目</b>
<div className="right">
<div className="mini-btn" onClick={() => setCreating(true)}>+ 新建</div>
</div>
</div>
{active.map((p) => (
<div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}>
<div className="pico" style={{ background: colorOf(p) }}>
{(p.name || '?').slice(0, 1)}
</div>
<div className="pmain">
<div className="ptitle">
{p.name}
{p.isPersonal && <span className="badge">个人</span>}
</div>
<div className="psub">
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
</div>
</div>
<div className="pcount">›</div>
</div>
))}
{active.length === 0 && (
<div className="empty" style={{ paddingTop: 90 }}>
<div className="ico">▤</div>
<p>还没有协作项目</p>
<div className="mini-btn solid" onClick={() => setCreating(true)}>新建项目</div>
</div>
)}
{archived.length > 0 && (
<>
<div className="proj-group">已归档({archived.length})</div>
{archived.map((p) => (
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
<div className="pmain">
<div className="ptitle">{p.name}</div>
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
</div>
<div className="pcount">›</div>
</div>
))}
</>
)}
<Dialog
visible={creating}
title="新建项目"
content={
<div className="field" style={{ marginTop: 8 }}>
<Input
placeholder="项目名称"
value={name}
onChange={setName}
maxLength={20}
/>
</div>
}
actions={[
{ key: 'cancel', text: '取消', onClick: () => setCreating(false) },
{ key: 'ok', text: saving ? '创建中…' : '创建', bold: true, onClick: create },
]}
onClose={() => setCreating(false)}
/>
</>
)
}
// 避免未使用告警
void Empty
+97
View File
@@ -0,0 +1,97 @@
// 任务模块前端: 自然日边界 + 保留选项可见性
// 与后端 server/task-domain/date.js 保持同一口径, 改动需同步
export const TZ = 'Asia/Shanghai'
export const RETENTION_LABEL = {
never: '永不显示',
today: '当天',
'3d': '3 天',
week: '本周',
month: '本月',
forever: '永久显示',
}
export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
export const STATUS_OPTIONS = ['todo', 'doing', 'done']
const FMT = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
})
export function toDateStr(d = new Date()) {
return FMT.format(d instanceof Date ? d : new Date(d))
}
function toUTC(s) {
const [y, m, d] = String(s).split('-').map(Number)
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
}
function fromUTC(dt) {
return dt.toISOString().slice(0, 10)
}
export function addDays(s, n) {
const d = toUTC(s)
d.setUTCDate(d.getUTCDate() + n)
return fromUTC(d)
}
/** 周一为一周第一天 */
export function startOfWeek(s) {
const dow = (toUTC(s).getUTCDay() + 6) % 7
return addDays(s, -dow)
}
export function startOfMonth(s) {
return String(s).slice(0, 8) + '01'
}
/** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */
export function retentionStart(option, now = new Date()) {
const today = toDateStr(now)
switch (option) {
case 'never': return { mode: 'never' }
case 'forever': return { mode: 'all' }
case 'today': return { mode: 'since', date: today }
case '3d': return { mode: 'since', date: addDays(today, -2) }
case 'week': return { mode: 'since', date: startOfWeek(today) }
case 'month': return { mode: 'since', date: startOfMonth(today) }
default: return { mode: 'all' }
}
}
/** 单条任务在某保留选项下是否可见 */
export function isVisibleByRetention(task, option, now = new Date()) {
if (task.status !== 'done') return true
const win = retentionStart(option, now)
if (win.mode === 'never') return false
if (win.mode === 'all') return true
if (!task.doneAt) return false
return toDateStr(task.doneAt) >= win.date
}
/** 按任务所属列的保留选项过滤任务 */
export function filterVisible(tasks, columns, now = new Date()) {
const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now))
}
/** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */
export function msToNextBoundary(option, now = new Date()) {
if (!['today', '3d', 'week', 'month'].includes(option)) return null
const today = toDateStr(now)
let next
if (option === 'today' || option === '3d') next = addDays(today, 1)
else if (option === 'week') next = addDays(startOfWeek(today), 7)
else {
const [y, m] = today.split('-').map(Number)
const nm = new Date(Date.UTC(y, m, 1))
next = nm.toISOString().slice(0, 10)
}
// 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00"
const target = new Date(next + 'T00:00:00+08:00').getTime()
return Math.max(1000, target - now.getTime())
}
+58
View File
@@ -0,0 +1,58 @@
// 任务模块: 设备端偏好 (不落库, 不跨设备)
// lastProjectId 上次访问的项目 (T-VW-07)
// viewMode 按项目记忆的视图 list | board (T-VW-03/06)
const KEY = 'iboard_task_prefs'
function read() {
try {
const raw = localStorage.getItem(KEY)
if (!raw) return { lastProjectId: null, viewMode: {} }
const obj = JSON.parse(raw)
return {
lastProjectId: obj.lastProjectId ?? null,
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
}
} catch {
return { lastProjectId: null, viewMode: {} }
}
}
function write(obj) {
try {
localStorage.setItem(KEY, JSON.stringify(obj))
} catch { /* 忽略配额错误 */ }
}
export function getLastProjectId() {
return read().lastProjectId
}
export function setLastProjectId(id) {
const cur = read()
cur.lastProjectId = id ?? null
write(cur)
}
/** 视图记忆: 无记录返回 null, 由调用方决定默认值 */
export function getViewMode(projectId) {
if (!projectId) return null
return read().viewMode[String(projectId)] || null
}
export function setViewMode(projectId, mode) {
if (!projectId) return
const cur = read()
cur.viewMode[String(projectId)] = mode
write(cur)
}
/** 清理已不可见的项目记录 */
export function prunePrefs(visibleIds) {
const set = new Set((visibleIds || []).map(String))
const cur = read()
if (cur.lastProjectId && !set.has(String(cur.lastProjectId))) cur.lastProjectId = null
for (const k of Object.keys(cur.viewMode)) {
if (!set.has(k)) delete cur.viewMode[k]
}
write(cur)
}