Files
iboard/src/pages/task/ProjectView.jsx
T
jif2.zhang 6c9cd3fd99
Docker Build and Push / build-image (push) Failing after 3m51s
feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
需求 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 缺失、领域逻辑用例全部通过
2026-10-24 11:21:16 +08:00

256 lines
8.3 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}