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
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react'
import { Dialog, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
// 项目成员 (T-PJ-06/07, T-AS-06, T-PM-02)
export default function ProjectMembers({ project, onClose, onChanged }) {
const { user } = useAuth()
const meId = user?.id
const [members, setMembers] = useState(project.members || [])
const [adding, setAdding] = useState(false)
const [email, setEmail] = useState('')
async function add() {
const e = email.trim()
if (!e) return
try {
await projectApi.addMember(project.id, { email: e })
setEmail('')
setAdding(false)
Toast.show({ content: '已添加' })
onChanged?.()
onClose?.()
} catch (err) {
Toast.show({ content: err.message })
}
}
async function remove(m, isSelf) {
const ok = await Dialog.confirm({
content: isSelf ? '退出该项目?退出后将不再可见。' : `移除成员「${m.name}」?`,
confirmText: isSelf ? '退出' : '移除',
})
if (!ok) return
try {
await projectApi.removeMember(project.id, m.userId)
setMembers((prev) => prev.filter((x) => x.userId !== m.userId))
Toast.show({ content: isSelf ? '已退出' : '已移除' })
onChanged?.()
if (isSelf) onClose?.()
} catch (err) {
Toast.show({ content: err.message })
}
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">{project.name}</span>
<div className="right">
{!project.isPersonal && (
<span className="mini-btn solid" onClick={() => setAdding(true)}>+ 添加</span>
)}
</div>
</div>
<div className="cs-body">
<div className="set-hint">
{project.isPersonal
? '个人默认项目不可添加成员。'
: '项目内所有成员权限均等、全读写;负责人与成员只是信息标记,不影响权限。'}
</div>
{members.map((m) => {
const isOwner = m.userId === project.ownerId
const isMe = m.userId === meId
return (
<div className="set-row" key={m.userId} style={{ padding: '12px 14px' }}>
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
{m.name}
{isMe && <span className="tag grey" style={{ marginLeft: 6 }}>我</span>}
{isOwner && <span className="tag grey" style={{ marginLeft: 6 }}>创建人</span>}
</span>
{isMe && !project.isPersonal && (
<span className="link danger" onClick={() => remove(m, true)}>退出</span>
)}
{!isMe && isOwner && <span className="link" style={{ color: '#999' }}>—</span>}
{!isMe && !isOwner && !project.isPersonal && (
<span className="link danger" onClick={() => remove(m, false)}>移除</span>
)}
</div>
)
})}
<div className="set-hint">
成员被移出项目后,其担任的负责人 / 成员标记会保留历史并显示为「已退出」,不清空、不报错。
</div>
</div>
<Dialog
visible={adding}
title="添加成员"
content={
<input
className="inp"
placeholder="输入成员邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setAdding(false) },
{ key: 'ok', text: '添加', bold: true, onClick: add },
]}
onClose={() => setAdding(false)}
/>
</div>
)
}