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,77 @@
|
||||
import { useState } from 'react'
|
||||
import { Toast } from 'antd-mobile'
|
||||
import TaskCard from '../../components/task/TaskCard.jsx'
|
||||
import useLongPressDrag from '../../components/task/useLongPressDrag.js'
|
||||
|
||||
// 看板视图 (T-BD-01, T-UI-07, T-ST-06/12)
|
||||
// 列横向滚动; 长按卡片拖拽换列 / 降为子任务
|
||||
export default function BoardView({
|
||||
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onQuickAdd,
|
||||
}) {
|
||||
const [overCol, setOverCol] = useState(null)
|
||||
|
||||
const drag = useLongPressDrag({
|
||||
onDropColumn: (task, columnId) => { setOverCol(null); onMoveColumn?.(task, columnId) },
|
||||
onDropTask: (task, parentId) => { setOverCol(null); onMakeSubtask?.(task, parentId) },
|
||||
onReject: (msg) => { setOverCol(null); Toast.show({ content: msg }) },
|
||||
})
|
||||
|
||||
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="board-wrap">
|
||||
{ordered.map((col) => {
|
||||
const list = (tasks || []).filter((t) => t.columnId === col.id)
|
||||
return (
|
||||
<div
|
||||
className={`bcol${overCol === col.id ? ' dragover' : ''}`}
|
||||
key={col.id}
|
||||
data-column-id={col.id}
|
||||
onDragEnter={() => setOverCol(col.id)}
|
||||
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
|
||||
>
|
||||
<div className="bcol-head">
|
||||
<span className="bcol-name">{col.name}</span>
|
||||
<span className="bcol-cnt">{list.length}</span>
|
||||
{col.boundStatus ? (
|
||||
<span className="bcol-bind">
|
||||
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bcol-bind off">未绑定</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bcol-body">
|
||||
{list.map((t) => (
|
||||
<TaskCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
dragging={drag.dragging?.task?.id === t.id}
|
||||
highlight={drag.dragging && drag.dragging.task.id !== t.id && overCol === col.id}
|
||||
bindDrag={drag.bind}
|
||||
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
|
||||
onToggleSub={onToggleSub}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
|
||||
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{drag.dragging && (
|
||||
<div
|
||||
className="drag-ghost"
|
||||
style={{ left: drag.dragging.x, top: drag.dragging.y }}
|
||||
>
|
||||
{drag.dragging.task.title}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import TaskRow from '../../components/task/TaskRow.jsx'
|
||||
|
||||
// 列表视图 (T-VW-01, T-ST-04/05)
|
||||
// 按看板列分组展示(列表不分组也允许, 这里默认分组更接近 Todoist)
|
||||
// 子任务缩进 + 折叠展开
|
||||
export default function ListView({
|
||||
tasks, columns, onToggle, onOpen, onAddSub,
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState({})
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return (
|
||||
<div className="empty" style={{ paddingTop: 90 }}>
|
||||
<div className="ico">✓</div>
|
||||
<p>这个项目还没有任务</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 分组: 按列顺序, 未匹配列的任务归到一起
|
||||
const groups = []
|
||||
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
for (const c of orderedCols) {
|
||||
const list = tasks.filter((t) => t.columnId === c.id)
|
||||
if (list.length) groups.push({ key: c.id, name: c.name, list })
|
||||
}
|
||||
const known = new Set(orderedCols.map((c) => c.id))
|
||||
const orphans = tasks.filter((t) => !known.has(t.columnId))
|
||||
if (orphans.length) groups.push({ key: 'other', name: '其他', list: orphans })
|
||||
|
||||
return (
|
||||
<div className="list-body">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="group-title">
|
||||
{g.name}
|
||||
<span className="cnt">{g.list.length}</span>
|
||||
</div>
|
||||
|
||||
{g.list.map((t) => {
|
||||
const kids = t.children || []
|
||||
const isCollapsed = !!collapsed[t.id]
|
||||
return (
|
||||
<div key={t.id}>
|
||||
<TaskRow
|
||||
task={t}
|
||||
onToggle={onToggle}
|
||||
onClick={() => onOpen(t)}
|
||||
onMore={() => onOpen(t)}
|
||||
/>
|
||||
{kids.length > 0 && (
|
||||
<>
|
||||
{!isCollapsed && kids.map((c) => (
|
||||
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} />
|
||||
))}
|
||||
<div
|
||||
className="collapse"
|
||||
onClick={() => setCollapsed((p) => ({ ...p, [t.id]: !p[t.id] }))}
|
||||
>
|
||||
{isCollapsed ? `展开 ${kids.length} 个子任务 ⌄` : `收起 ${kids.length} 个子任务 ⌃`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{kids.length === 0 && onAddSub && (
|
||||
<div className="sub-add-hint" onClick={() => onAddSub(t)}>+ 子任务</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dialog, Popup, Toast } from 'antd-mobile'
|
||||
import { projectApi, taskApi } from '../../api/task.js'
|
||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
|
||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||
|
||||
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
|
||||
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
|
||||
const [form, setForm] = useState(() => ({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
extras: task.extras || [],
|
||||
memberIds: (task.members || []).map((m) => m.userId),
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
tags: task.tags || [],
|
||||
}))
|
||||
const [subs, setSubs] = useState(task.children || [])
|
||||
const [newSub, setNewSub] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
|
||||
|
||||
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
|
||||
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
extras: task.extras || [],
|
||||
memberIds: (task.members || []).map((m) => m.userId),
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
tags: task.tags || [],
|
||||
})
|
||||
setSubs(task.children || [])
|
||||
}, [task])
|
||||
|
||||
async function save() {
|
||||
if (!form.title.trim()) { Toast.show({ content: '标题不能为空' }); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const r = await taskApi.update(task.id, {
|
||||
title: form.title.trim(),
|
||||
description: form.description,
|
||||
extras: form.extras,
|
||||
assigneeId: form.assigneeId,
|
||||
memberIds: form.memberIds,
|
||||
status: form.status,
|
||||
tags: form.tags,
|
||||
})
|
||||
Toast.show({ content: '已保存' })
|
||||
onSaved?.(r.item)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addSub() {
|
||||
const t = newSub.trim()
|
||||
if (!t) return
|
||||
try {
|
||||
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
||||
setSubs((prev) => [...prev, r.item])
|
||||
setNewSub('')
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSub(sub) {
|
||||
const next = sub.status === 'done' ? 'todo' : 'done'
|
||||
try {
|
||||
const r = await taskApi.update(sub.id, { status: next })
|
||||
setSubs((prev) => prev.map((s) => (s.id === sub.id ? r.item : s)))
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTask() {
|
||||
const ok = await Dialog.confirm({
|
||||
content: subs.length
|
||||
? `删除该任务?将一并删除 ${subs.length} 条子任务。`
|
||||
: '删除该任务?',
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await taskApi.remove(task.id)
|
||||
Toast.show({ content: '已删除' })
|
||||
onSaved?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function openCopy() {
|
||||
try {
|
||||
const d = await projectApi.list()
|
||||
setCopies((d.items || []).filter((p) => p.id !== task.projectId && !p.archived))
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function doCopy(projectId) {
|
||||
try {
|
||||
const r = await taskApi.copy(task.id, projectId)
|
||||
const dropped = r.dropped || {}
|
||||
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
|
||||
Toast.show({
|
||||
content: lost.length
|
||||
? `已拷贝到目标项目;${lost.join('、')} 不是该项目成员,已不保留`
|
||||
: '已拷贝到目标项目',
|
||||
})
|
||||
setCopies(null)
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px 14px' }}
|
||||
>
|
||||
<div className="sheet-title">{task.title}</div>
|
||||
<div className="sheet-sub">
|
||||
{task.column?.name} · {STATUS_LABEL[task.status]}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>标题</label>
|
||||
<input
|
||||
className="inp"
|
||||
value={form.title}
|
||||
maxLength={200}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
className="inp"
|
||||
rows={2}
|
||||
placeholder="补充说明…"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>状态</label>
|
||||
<div className="seg">
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={form.status === s ? 'on' : ''}
|
||||
onClick={() => setForm((p) => ({ ...p, status: s }))}
|
||||
>
|
||||
{STATUS_LABEL[s]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>负责人</label>
|
||||
<div className="pick-row" onClick={() => setPicker('assignee')}>
|
||||
{assigneeName ? (
|
||||
<div className="avatar">{assigneeName.slice(0, 1)}</div>
|
||||
) : (
|
||||
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
|
||||
)}
|
||||
<span className="pn">{assigneeName || '未指派'}</span>
|
||||
<span className="link">更换</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>任务成员</label>
|
||||
<div className="pick-row" onClick={() => setPicker('members')}>
|
||||
<div className="avatar" style={{ background: '#eef2f7', color: '#7b8794' }}>
|
||||
{form.memberIds.length || 0}
|
||||
</div>
|
||||
<span className="pn" style={{ fontSize: 12.5, color: form.memberIds.length ? 'inherit' : '#999' }}>
|
||||
{memberNames.length ? memberNames.join('、') : '未选择成员'}
|
||||
</span>
|
||||
<span className="link">编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExtrasCard
|
||||
extras={form.extras}
|
||||
onChange={(next) => setForm((p) => ({ ...p, extras: next }))}
|
||||
/>
|
||||
|
||||
<div className="section-h">子任务 <span className="tag">{subs.length}</span></div>
|
||||
{subs.map((s) => (
|
||||
<div className="pick-row" key={s.id} onClick={() => toggleSub(s)}>
|
||||
<span className={`ck${s.status === 'done' ? ' on' : ''}`} />
|
||||
<span className="pn">{s.title}</span>
|
||||
<StatusDot status={s.status} />
|
||||
</div>
|
||||
))}
|
||||
<div className="extra-row" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
className="inp"
|
||||
placeholder="添加子任务,回车确认"
|
||||
value={newSub}
|
||||
maxLength={200}
|
||||
onChange={(e) => setNewSub(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
|
||||
<div className="btn-ghost" onClick={openCopy}>拷贝到其他项目</div>
|
||||
<div className="btn-danger" onClick={removeTask}>
|
||||
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
|
||||
</div>
|
||||
|
||||
<UserPicker
|
||||
visible={picker === 'assignee'}
|
||||
title="选择负责人"
|
||||
members={members}
|
||||
value={form.assigneeId}
|
||||
onClose={() => setPicker(null)}
|
||||
onConfirm={(uid) => setForm((p) => ({ ...p, assigneeId: uid }))}
|
||||
/>
|
||||
<UserPicker
|
||||
visible={picker === 'members'}
|
||||
title="选择任务成员"
|
||||
multiple
|
||||
members={members}
|
||||
value={form.memberIds}
|
||||
onClose={() => setPicker(null)}
|
||||
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
|
||||
/>
|
||||
|
||||
<Popup
|
||||
visible={!!copies}
|
||||
onMaskClick={() => setCopies(null)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}
|
||||
>
|
||||
<div className="sheet-title">跨项目拷贝</div>
|
||||
<div className="sheet-sub">源任务保持不变;新任务的创建人是你</div>
|
||||
{(copies || []).length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
|
||||
{(copies || []).map((p) => (
|
||||
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id)}>
|
||||
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
|
||||
<span className="pn">{p.name}</span>
|
||||
<span className="link">拷贝</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="btn-ghost" onClick={() => setCopies(null)}>取消</div>
|
||||
</Popup>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Dialog, DotLoading, Empty, Input, Toast } from 'antd-mobile'
|
||||
import PageHeader from '../../components/PageHeader.jsx'
|
||||
import { projectApi } from '../../api/task.js'
|
||||
import { getLastProjectId, setLastProjectId, prunePrefs } from './prefs.js'
|
||||
import ProjectView from './ProjectView.jsx'
|
||||
|
||||
// 任务模块容器 (T-PM-06)
|
||||
// 无 :pid -> 项目列表
|
||||
// 有 :pid -> 该项目视图(列表/看板)
|
||||
export default function TaskPage() {
|
||||
const { pid } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [projects, setProjects] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
const load = useCallback(() => {
|
||||
setErr('')
|
||||
projectApi.list()
|
||||
.then((d) => {
|
||||
const items = d.items || []
|
||||
setProjects(items)
|
||||
prunePrefs(items.map((p) => p.id))
|
||||
})
|
||||
.catch((e) => setErr(e.message))
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// 记住上次访问的项目 (T-VW-07)
|
||||
useEffect(() => {
|
||||
if (pid) setLastProjectId(Number(pid))
|
||||
}, [pid])
|
||||
|
||||
if (pid) {
|
||||
return <ProjectView projectId={Number(pid)} onBack={() => navigate('/task')} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<PageHeader title="任务" />
|
||||
<div className="task-content">
|
||||
<ProjectList
|
||||
projects={projects}
|
||||
err={err}
|
||||
onRetry={load}
|
||||
onOpen={(id) => {
|
||||
setLastProjectId(id)
|
||||
navigate(`/task/${id}`)
|
||||
}}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<div className="ico err">!</div>
|
||||
<p>{err}</p>
|
||||
<div className="mini-btn solid" onClick={onRetry}>重试</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!projects) {
|
||||
return (
|
||||
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
|
||||
)
|
||||
}
|
||||
|
||||
const active = projects.filter((p) => !p.archived)
|
||||
const archived = projects.filter((p) => p.archived)
|
||||
|
||||
async function create() {
|
||||
const t = name.trim()
|
||||
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
await projectApi.create({ name: t })
|
||||
setName('')
|
||||
setCreating(false)
|
||||
Toast.show({ content: '已创建' })
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
||||
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="proj-head">
|
||||
<b>我的项目</b>
|
||||
<div className="right">
|
||||
<div className="mini-btn" onClick={() => setCreating(true)}>+ 新建</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active.map((p) => (
|
||||
<div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}>
|
||||
<div className="pico" style={{ background: colorOf(p) }}>
|
||||
{(p.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<div className="pmain">
|
||||
<div className="ptitle">
|
||||
{p.name}
|
||||
{p.isPersonal && <span className="badge">个人</span>}
|
||||
</div>
|
||||
<div className="psub">
|
||||
{p.taskCount ?? 0} 个任务 · {p.memberCount ?? 1} 人
|
||||
</div>
|
||||
</div>
|
||||
<div className="pcount">›</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{active.length === 0 && (
|
||||
<div className="empty" style={{ paddingTop: 90 }}>
|
||||
<div className="ico">▤</div>
|
||||
<p>还没有协作项目</p>
|
||||
<div className="mini-btn solid" onClick={() => setCreating(true)}>新建项目</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{archived.length > 0 && (
|
||||
<>
|
||||
<div className="proj-group">已归档({archived.length})</div>
|
||||
{archived.map((p) => (
|
||||
<div className="proj-item" key={p.id} style={{ opacity: 0.65 }} onClick={() => onOpen(p.id)}>
|
||||
<div className="pico" style={{ background: '#a8b0ba' }}>{(p.name || '?').slice(0, 1)}</div>
|
||||
<div className="pmain">
|
||||
<div className="ptitle">{p.name}</div>
|
||||
<div className="psub">已归档 · {p.taskCount ?? 0} 个任务</div>
|
||||
</div>
|
||||
<div className="pcount">›</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
visible={creating}
|
||||
title="新建项目"
|
||||
content={
|
||||
<div className="field" style={{ marginTop: 8 }}>
|
||||
<Input
|
||||
placeholder="项目名称"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
maxLength={20}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
{ key: 'cancel', text: '取消', onClick: () => setCreating(false) },
|
||||
{ key: 'ok', text: saving ? '创建中…' : '创建', bold: true, onClick: create },
|
||||
]}
|
||||
onClose={() => setCreating(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 避免未使用告警
|
||||
void Empty
|
||||
@@ -0,0 +1,97 @@
|
||||
// 任务模块前端: 自然日边界 + 保留选项可见性
|
||||
// 与后端 server/task-domain/date.js 保持同一口径, 改动需同步
|
||||
export const TZ = 'Asia/Shanghai'
|
||||
|
||||
export const RETENTION_LABEL = {
|
||||
never: '永不显示',
|
||||
today: '当天',
|
||||
'3d': '3 天',
|
||||
week: '本周',
|
||||
month: '本月',
|
||||
forever: '永久显示',
|
||||
}
|
||||
|
||||
export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
|
||||
|
||||
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
|
||||
export const STATUS_OPTIONS = ['todo', 'doing', 'done']
|
||||
|
||||
const FMT = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
})
|
||||
|
||||
export function toDateStr(d = new Date()) {
|
||||
return FMT.format(d instanceof Date ? d : new Date(d))
|
||||
}
|
||||
|
||||
function toUTC(s) {
|
||||
const [y, m, d] = String(s).split('-').map(Number)
|
||||
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
|
||||
}
|
||||
|
||||
function fromUTC(dt) {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function addDays(s, n) {
|
||||
const d = toUTC(s)
|
||||
d.setUTCDate(d.getUTCDate() + n)
|
||||
return fromUTC(d)
|
||||
}
|
||||
|
||||
/** 周一为一周第一天 */
|
||||
export function startOfWeek(s) {
|
||||
const dow = (toUTC(s).getUTCDay() + 6) % 7
|
||||
return addDays(s, -dow)
|
||||
}
|
||||
|
||||
export function startOfMonth(s) {
|
||||
return String(s).slice(0, 8) + '01'
|
||||
}
|
||||
|
||||
/** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */
|
||||
export function retentionStart(option, now = new Date()) {
|
||||
const today = toDateStr(now)
|
||||
switch (option) {
|
||||
case 'never': return { mode: 'never' }
|
||||
case 'forever': return { mode: 'all' }
|
||||
case 'today': return { mode: 'since', date: today }
|
||||
case '3d': return { mode: 'since', date: addDays(today, -2) }
|
||||
case 'week': return { mode: 'since', date: startOfWeek(today) }
|
||||
case 'month': return { mode: 'since', date: startOfMonth(today) }
|
||||
default: return { mode: 'all' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条任务在某保留选项下是否可见 */
|
||||
export function isVisibleByRetention(task, option, now = new Date()) {
|
||||
if (task.status !== 'done') return true
|
||||
const win = retentionStart(option, now)
|
||||
if (win.mode === 'never') return false
|
||||
if (win.mode === 'all') return true
|
||||
if (!task.doneAt) return false
|
||||
return toDateStr(task.doneAt) >= win.date
|
||||
}
|
||||
|
||||
/** 按任务所属列的保留选项过滤任务 */
|
||||
export function filterVisible(tasks, columns, now = new Date()) {
|
||||
const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
|
||||
return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now))
|
||||
}
|
||||
|
||||
/** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */
|
||||
export function msToNextBoundary(option, now = new Date()) {
|
||||
if (!['today', '3d', 'week', 'month'].includes(option)) return null
|
||||
const today = toDateStr(now)
|
||||
let next
|
||||
if (option === 'today' || option === '3d') next = addDays(today, 1)
|
||||
else if (option === 'week') next = addDays(startOfWeek(today), 7)
|
||||
else {
|
||||
const [y, m] = today.split('-').map(Number)
|
||||
const nm = new Date(Date.UTC(y, m, 1))
|
||||
next = nm.toISOString().slice(0, 10)
|
||||
}
|
||||
// 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00"
|
||||
const target = new Date(next + 'T00:00:00+08:00').getTime()
|
||||
return Math.max(1000, target - now.getTime())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 任务模块: 设备端偏好 (不落库, 不跨设备)
|
||||
// lastProjectId 上次访问的项目 (T-VW-07)
|
||||
// viewMode 按项目记忆的视图 list | board (T-VW-03/06)
|
||||
const KEY = 'iboard_task_prefs'
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return { lastProjectId: null, viewMode: {} }
|
||||
const obj = JSON.parse(raw)
|
||||
return {
|
||||
lastProjectId: obj.lastProjectId ?? null,
|
||||
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
|
||||
}
|
||||
} catch {
|
||||
return { lastProjectId: null, viewMode: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function write(obj) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(obj))
|
||||
} catch { /* 忽略配额错误 */ }
|
||||
}
|
||||
|
||||
export function getLastProjectId() {
|
||||
return read().lastProjectId
|
||||
}
|
||||
|
||||
export function setLastProjectId(id) {
|
||||
const cur = read()
|
||||
cur.lastProjectId = id ?? null
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 视图记忆: 无记录返回 null, 由调用方决定默认值 */
|
||||
export function getViewMode(projectId) {
|
||||
if (!projectId) return null
|
||||
return read().viewMode[String(projectId)] || null
|
||||
}
|
||||
|
||||
export function setViewMode(projectId, mode) {
|
||||
if (!projectId) return
|
||||
const cur = read()
|
||||
cur.viewMode[String(projectId)] = mode
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 清理已不可见的项目记录 */
|
||||
export function prunePrefs(visibleIds) {
|
||||
const set = new Set((visibleIds || []).map(String))
|
||||
const cur = read()
|
||||
if (cur.lastProjectId && !set.has(String(cur.lastProjectId))) cur.lastProjectId = null
|
||||
for (const k of Object.keys(cur.viewMode)) {
|
||||
if (!set.has(k)) delete cur.viewMode[k]
|
||||
}
|
||||
write(cur)
|
||||
}
|
||||
Reference in New Issue
Block a user