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
+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>
)
}