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 缺失、领域逻辑用例全部通过
256 lines
8.3 KiB
React
256 lines
8.3 KiB
React
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>
|
||
)
|
||
}
|