feat(V0.2): 任务操作菜单 / 状态指示 / 移动与转子任务 / 移除扩展信息
Docker Build and Push / build-image (push) Successful in 4m13s

1. 任务 ⋯ 菜单 (列表行与看板卡片): 编辑 / 拷贝到其他项目 / 删除
   (antd-mobile ActionSheet, 带任务名标题; 拷贝抽出为 CopyTaskPopup 复用)
2. 点击任务非按钮区域 = 打开编辑页 (原本即如此, ⋯ 不再误触为编辑)
3. 修复状态选择器堆成三行: 此前 .seg 只有 .pv-head 作用域样式,
   详情弹层裸用导致纵向堆叠; 新增通用 .seg 分段样式, 当前项高亮点按即切
4. 移除任务的扩展信息 UI (T-TK-04~07 标注已取消, 决策 35):
   ExtrasCard 组件删除, DB extras 字段保留不再使用;
   项目相关长文本由项目信息块 (T-PJ-13) 承载
5. 任务移动入口: 详情页新增「看板列」ActionSheet 换列(联动裁决不变)
   与「层级」区(转为子任务=选父任务 / 转为顶层), 覆盖无法拖拽的场景
6. 列表页移除「+ 子任务」入口: 添加子任务只在详情页, 或经转子任务

已验证: 99 文件语法 0 错误、导入 0 缺失、文档 149 条/决策 35 连续
This commit is contained in:
jif2.zhang
2026-10-24 21:51:26 +08:00
parent b71ceca3f2
commit 506db0795b
10 changed files with 252 additions and 146 deletions
+2 -1
View File
@@ -6,7 +6,7 @@ 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,
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onMore, onQuickAdd,
}) {
const [overCol, setOverCol] = useState(null)
@@ -54,6 +54,7 @@ export default function BoardView({
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
onToggleSub={onToggleSub}
onToggle={onToggle}
onMore={onMore}
/>
))}
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
+5 -8
View File
@@ -2,10 +2,10 @@ 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,
tasks, columns, onToggle, onOpen, onMore,
}) {
const [collapsed, setCollapsed] = useState({})
@@ -47,12 +47,12 @@ export default function ListView({
task={t}
onToggle={onToggle}
onClick={() => onOpen(t)}
onMore={() => onOpen(t)}
onMore={onMore}
/>
{kids.length > 0 && (
<>
{!isCollapsed && kids.map((c) => (
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} />
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} onMore={onMore} />
))}
<div
className="collapse"
@@ -62,9 +62,6 @@ export default function ListView({
</div>
</>
)}
{kids.length === 0 && onAddSub && (
<div className="sub-add-hint" onClick={() => onAddSub(t)}>+ 子任务</div>
)}
</div>
)
})}
+53 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DotLoading, Toast } from 'antd-mobile'
import { ActionSheet, Dialog, DotLoading, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { filterVisible, msToNextBoundary } from './date.js'
import { getViewMode, setViewMode } from './prefs.js'
@@ -7,6 +7,7 @@ import ListView from './ListView.jsx'
import BoardView from './BoardView.jsx'
import TaskDetail from './TaskDetail.jsx'
import ProjectManage from './ProjectManage.jsx'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
// 嵌在任务首屏下部 (T-VW-09); 头部仅保留「项目管理」与「+ 新增」(T-PJ-11)
@@ -20,6 +21,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
const [showManage, setShowManage] = useState(false)
const [adding, setAdding] = useState(false)
const [tick, setTick] = useState(0) // 保留窗口重算触发
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
const load = useCallback(() => {
setErr('')
@@ -136,6 +138,44 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
}
}
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 */
function openTaskMenu(task) {
ActionSheet.show({
extra: (
<div className="as-extra">
<div className="as-title">{task.title}</div>
</div>
),
actions: [
{ text: '编辑', key: 'edit' },
{ text: '拷贝到其他项目', key: 'copy' },
{ text: '删除', key: 'delete', bold: true, danger: true },
],
cancelText: '取消',
onAction: async (item) => {
if (item.key === 'edit') setOpenTask(task)
else if (item.key === 'copy') setCopyTask(task)
else if (item.key === 'delete') {
const kids = task.children?.length || 0
const ok = await Dialog.confirm({
content: kids
? `删除「${task.title}」?将一并删除 ${kids} 条子任务。`
: `删除「${task.title}」?`,
confirmText: '删除',
})
if (!ok) return
try {
await taskApi.remove(task.id)
Toast.show({ content: '已删除' })
load()
} catch (e) {
Toast.show({ content: e.message })
}
}
},
})
}
if (err) {
return (
<div className="pv-embed">
@@ -196,7 +236,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
columns={columns}
onToggle={toggleDone}
onOpen={(t) => setOpenTask(t)}
onAddSub={(parent) => setOpenTask(parent)}
onMore={openTaskMenu}
/>
) : (
<BoardView
@@ -207,6 +247,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
onOpen={(t) => setOpenTask(t)}
onToggleSub={(c) => toggleDone(c)}
onToggle={(t) => toggleDone(t)}
onMore={openTaskMenu}
onQuickAdd={() => { setAdding(true) }}
/>
)}
@@ -217,12 +258,22 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
task={openTask}
columns={columns}
members={project.members || []}
tasks={tasks || []}
onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }}
onChanged={load}
/>
)}
{copyTask && (
<CopyTaskPopup
task={copyTask}
visible
onClose={() => setCopyTask(null)}
onDone={load}
/>
)}
{showManage && (
<ProjectManage
project={project}
+102 -58
View File
@@ -1,19 +1,21 @@
import { useEffect, useState } from 'react'
import { Dialog, Popup, Toast } from 'antd-mobile'
import { projectApi, taskApi } from '../../api/task.js'
import { ActionSheet, Dialog, Popup, Toast } from 'antd-mobile'
import { 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'
import CopyTaskPopup from '../../components/task/CopyTaskPopup.jsx'
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
import useOverlayBack from '../../hooks/useOverlayBack.js'
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
// 任务详情 (T-TK-*, T-ST-03, T-AS-05)
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
export default function TaskDetail({ task, columns, members, tasks, 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,
@@ -22,22 +24,28 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
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 [picker, setPicker] = useState(null) // 'assignee' | 'members'
const [copyOpen, setCopyOpen] = useState(false)
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡 (bug#4)
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
const kb = useKeyboardInset()
// 返回键: 先关详情弹层, 不离开任务页
useOverlayBack(true, onClose)
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
const currentCol = (columns || []).find((c) => c.id === task.columnId)
const isSubtask = task.parentId != null
// 可作为父任务的候选: 同项目顶层任务, 排除自己与已有子任务的任务 (T-ST-01/13)
const parentCandidates = (tasks || []).filter(
(t) => t.id !== task.id && (t.children || []).length === 0,
)
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,
@@ -53,7 +61,6 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
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,
@@ -68,6 +75,39 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
}
}
/** 换列: 服务端按目标列绑定裁决状态 (T-BD-04/07) */
function pickColumn() {
ActionSheet.show({
actions: (columns || []).map((c) => ({
text: c.name + (c.id === task.columnId ? ' ✓' : ''),
key: String(c.id),
})),
cancelText: '取消',
onAction: async (item) => {
const columnId = Number(item.key)
if (columnId === task.columnId) return
try {
const r = await taskApi.update(task.id, { columnId })
Toast.show({ content: `已移入 ${(columns || []).find((c) => c.id === columnId)?.name || '目标列'}` })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
}
},
})
}
/** 转为子任务 (T-ST-12) / 转为顶层 */
async function changeParent(parentId) {
try {
const r = await taskApi.update(task.id, { parentId })
Toast.show({ content: parentId ? '已转为子任务' : '已转为顶层任务' })
onSaved?.(r.item)
} catch (e) {
Toast.show({ content: e.message })
}
}
async function addSub() {
const t = newSub.trim()
if (!t) return
@@ -109,32 +149,6 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
}
}
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
@@ -145,13 +159,12 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
borderTopRightRadius: 12,
maxHeight: `calc(88vh - ${kb}px)`,
overflowY: 'auto',
// 键盘弹出时抬升内容 (kb), 否则"添加子任务"输入框被键盘遮挡
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">{task.title}</div>
<div className="sheet-sub">
{task.column?.name} · {STATUS_LABEL[task.status]}
{currentCol?.name} · {STATUS_LABEL[task.status]}
</div>
<div className="field">
@@ -175,6 +188,7 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
/>
</div>
{/* 状态: 三段式, 当前项高亮, 点按即切换 */}
<div className="field">
<label>状态</label>
<div className="seg">
@@ -190,6 +204,31 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
</div>
</div>
{/* 看板列: 点击换列, 状态随目标列联动 */}
<div className="field">
<label>看板列</label>
<div className="pick-row" onClick={pickColumn}>
<span className="pn">{currentCol?.name || '未分组'}</span>
<span className="link">更换 ›</span>
</div>
</div>
{/* 层级: 转为子任务 / 转为顶层 (T-ST-12) */}
<div className="field">
<label>层级</label>
{isSubtask ? (
<div className="pick-row" onClick={() => changeParent(null)}>
<span className="pn">子任务 · 可转为顶层任务</span>
<span className="link">转为顶层</span>
</div>
) : (
<div className="pick-row" onClick={() => setParentPick(true)}>
<span className="pn">顶层任务 · 可挂到其他任务下</span>
<span className="link">转为子任务 ›</span>
</div>
)}
</div>
<div className="field">
<label>负责人</label>
<div className="pick-row" onClick={() => setPicker('assignee')}>
@@ -216,11 +255,6 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
</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)}>
@@ -238,14 +272,13 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
onChange={(e) => setNewSub(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
onFocus={(e) => {
// 键盘弹出动画 (~300ms) 后把输入框滚到可视区中部, 双保险防遮挡
setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300)
}}
/>
</div>
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
<div className="btn-ghost" onClick={openCopy}>拷贝到其他项目</div>
<div className="btn-ghost" onClick={() => setCopyOpen(true)}>拷贝到其他项目</div>
<div className="btn-danger" onClick={removeTask}>
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
</div>
@@ -268,22 +301,33 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
/>
<CopyTaskPopup
task={task}
visible={copyOpen}
onClose={() => setCopyOpen(false)}
onDone={onChanged}
/>
{/* 转为子任务: 从项目内顶层任务中选父任务 */}
<Popup
visible={!!copies}
onMaskClick={() => setCopies(null)}
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}
visible={parentPick}
onMaskClick={() => setParentPick(false)}
bodyStyle={{
borderTopLeftRadius: 12, borderTopRightRadius: 12,
maxHeight: '70vh', overflowY: 'auto',
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
}}
>
<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 className="sheet-title">转为子任务</div>
<div className="sheet-sub">选择父任务(仅支持一级;已有子任务的任务不能作为父任务)</div>
{parentCandidates.length === 0 && <div className="extras-empty">没有可选的父任务</div>}
{parentCandidates.map((t) => (
<div className="pick-row" key={t.id} onClick={() => { setParentPick(false); changeParent(t.id) }}>
<span className="pn">{t.title}</span>
<span className="link">选定</span>
</div>
))}
<div className="btn-ghost" onClick={() => setCopies(null)}>取消</div>
<div className="btn-ghost" onClick={() => setParentPick(false)}>取消</div>
</Popup>
</Popup>
)