Files
iboard/src/pages/task/ColumnSettings.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

195 lines
7.0 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 { 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>
)
}