feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
Docker Build and Push / build-image (push) Failing after 3m51s
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user