feat(V0.2): 任务操作菜单 / 状态指示 / 移动与转子任务 / 移除扩展信息
Docker Build and Push / build-image (push) Successful in 4m13s
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:
@@ -145,10 +145,10 @@
|
||||
| T-TK-01 | `Task` 表 | 字段:`title`/`description`/`projectId`/`columnId`/`status`/`assigneeId`/`creatorId`/`doneAt`/`sortOrder` + 关联成员/标签/扩展 |
|
||||
| T-TK-02 | `title` 非空校验 | 其余字段允许空;trim 后为空 ⇒ 400 |
|
||||
| T-TK-03 | `status` 枚举三值 | DB 层用 `VARCHAR(8)` + 应用层校验(避免 ENUM 改值困难) |
|
||||
| T-TK-04 | `TaskDetail` → `ExtrasCard` 组件 | 任意条目增删改,无字段上限(建议 ≤ 20 条) |
|
||||
| T-TK-05 | `Task.extras` JSON | 结构 `[{label, value, url?}]`;`value` 支持纯文本或 URL |
|
||||
| T-TK-06 | `ExtrasCard` 默认 `collapsed=true` | 首次打开详情为收起态 |
|
||||
| T-TK-07 | 无必填校验 | 全空可保存 |
|
||||
| T-TK-04 | **已移除 UI**(2026-10-24):`ExtrasCard` 组件删除;信息记录由项目信息块(T-PJ-13)承载 |
|
||||
| T-TK-05 | **随 T-TK-04 移除**;`extras` 字段在 DB 保留不再使用 |
|
||||
| T-TK-06 | **随 T-TK-04 移除** |
|
||||
| T-TK-07 | **随 T-TK-04 移除** |
|
||||
| T-TK-08 | `Task.assigneeId` 单值 | 重复设置以最后一次为准 |
|
||||
| T-TK-09 | 新建时服务端默认赋值 | 未显式传 `assigneeId` ⇒ 取 `req.user.id`(B-10) |
|
||||
| T-TK-10 | `assigneeId` 允许 `null` | 显式传 `null` 即清空,UI 显示「未指派」 |
|
||||
|
||||
@@ -230,10 +230,10 @@
|
||||
| T-TK-01 | 任务包含**基础字段**:标题(必填)、描述、所属项目、看板列、任务状态、负责人、任务成员、标签、创建人、创建时间、更新时间 | 新建任务后上述字段均可正确保存与读取 | P0 |
|
||||
| T-TK-02 | 标题为**唯一必填**字段,其余字段均可为空 | 仅填标题即可保存成功;仅清空标题时拒绝保存并提示 | P0 |
|
||||
| T-TK-03 | 任务状态为三值枚举:`未开始` / `执行中` / `完成` | 任一任务的状态只能取这三个值之一,不存在第四种取值 | P0 |
|
||||
| T-TK-04 | 内置**扩展信息卡片**:可增加任意数量的信息条目,承载开发源码等扩展信息 | 可新增 / 编辑 / 删除任意条目,条目数量不受固定字段限制 | P0 |
|
||||
| T-TK-05 | 扩展信息条目支持**任意名称 + 值**,值可为单行文本或链接 | 可录入「仓库 = gitlab/xxx」「分支 = dev」「链接 = https://…」等自由内容 | P0 |
|
||||
| T-TK-06 | 扩展信息卡片**可折叠**且**默认收起** | 首次打开任务详情,扩展区为收起状态;可展开 / 收起 | P0 |
|
||||
| T-TK-07 | 扩展信息卡片**全部非必填**,全部留空不影响任务保存 | 只填标题保存后,扩展区为空仍成功 | P0 |
|
||||
| T-TK-04 | ~~内置扩展信息卡片~~ **已移除**(2026-10-24 评审):相关信息记录改由**项目信息块**承载(T-PJ-13) | — | 已取消 |
|
||||
| T-TK-05 | ~~扩展信息条目~~ **随 T-TK-04 一并移除** | — | 已取消 |
|
||||
| T-TK-06 | ~~扩展信息卡片折叠~~ **随 T-TK-04 一并移除** | — | 已取消 |
|
||||
| T-TK-07 | ~~扩展信息非必填~~ **随 T-TK-04 一并移除** | — | 已取消 |
|
||||
| T-TK-08 | 每条任务有**负责人(单人)** | 任务最多只有一个负责人,重复设置时以最后一次为准 | P0 |
|
||||
| T-TK-09 | 新建任务时**负责人默认填入创建者本人** | 新建任务后未手动修改时,负责人即为创建者 | P0 |
|
||||
| T-TK-10 | 负责人**允许清空为空** | 清空后任务可正常保存,展示为「未指派」 | P0 |
|
||||
@@ -432,6 +432,7 @@
|
||||
| 32 | 项目信息管理包含什么? | 改名、归档 / 取消归档、删除(个人项目禁用)、退出(创建人可退),入口在项目管理内(T-PJ-12) |
|
||||
| 33 | 任务详情底部按钮遮挡? | 弹层底部预留**安全区 + 12px** 距离;小屏下弹层可滚动,底部按钮完整可见(T-UI-11) |
|
||||
| 34 | 项目相关长文本信息记录在哪? | 项目管理内新增**信息块**分区:任意多个 {标题, 长文本} 块,独立编辑,属项目级数据(T-PJ-13) |
|
||||
| 35 | 任务级「扩展信息」的去留? | **移除 UI**(2026-10-24 评审):任务不再带扩展信息,项目相关长文本由项目信息块(T-PJ-13)承载;DB 字段保留不用 |
|
||||
|
||||
**仍待评审确认(影响实现口径):**
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { DotLoading, Popup, Toast } from 'antd-mobile'
|
||||
import { projectApi, taskApi } from '../../api/task.js'
|
||||
|
||||
// 跨项目拷贝弹层 (T-CP-*): 选择目标项目 → 深拷贝; 人员不属于目标项目时丢弃并提示
|
||||
export default function CopyTaskPopup({ task, visible, onClose, onDone }) {
|
||||
const [projects, setProjects] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
setProjects(null)
|
||||
projectApi.list()
|
||||
.then((d) => setProjects((d.items || []).filter((p) => p.id !== task.projectId && !p.archived)))
|
||||
.catch((e) => {
|
||||
Toast.show({ content: e.message })
|
||||
setProjects([])
|
||||
})
|
||||
}, [visible, task.projectId])
|
||||
|
||||
async function doCopy(projectId, name) {
|
||||
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
|
||||
? `已拷贝到「${name}」;${lost.join('、')} 不是该项目成员,已不保留`
|
||||
: `已拷贝到「${name}」`,
|
||||
})
|
||||
onClose?.()
|
||||
onDone?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
onMaskClick={onClose}
|
||||
bodyStyle={{
|
||||
borderTopLeftRadius: 12, borderTopRightRadius: 12,
|
||||
padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)',
|
||||
}}
|
||||
>
|
||||
<div className="sheet-title">跨项目拷贝</div>
|
||||
<div className="sheet-sub">「{task.title}」→ 选择目标项目;源任务保持不变</div>
|
||||
|
||||
{!projects && <div className="empty" style={{ padding: '20px 0' }}><DotLoading color="#1677ff" /> 加载项目…</div>}
|
||||
{projects && projects.length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
|
||||
{(projects || []).map((p) => (
|
||||
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id, p.name)}>
|
||||
<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={onClose}>取消</div>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// 扩展信息卡片 (T-TK-04~07): 任意条目, 默认收起, 全部非必填
|
||||
// 条目结构 { label, value, url? }
|
||||
export default function ExtrasCard({ extras = [], onChange, readOnly = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const list = Array.isArray(extras) ? extras : []
|
||||
|
||||
const update = (idx, key, val) => {
|
||||
const next = list.map((e, i) => (i === idx ? { ...e, [key]: val } : e))
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const add = () => onChange?.([...list, { label: '', value: '', url: '' }])
|
||||
const remove = (idx) => onChange?.(list.filter((_, i) => i !== idx))
|
||||
|
||||
return (
|
||||
<div className="extras">
|
||||
<div className="extras-head" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="caret">{open ? '▾' : '▸'}</span>
|
||||
<span>扩展信息</span>
|
||||
{list.length > 0 && <span className="tag">{list.length}</span>}
|
||||
<span className="cv">{open ? '收起' : '默认收起 · 全部非必填'}</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="extras-body">
|
||||
{list.length === 0 && (
|
||||
<div className="extras-empty">暂无条目,可记录仓库 / 分支 / 链接等任意信息</div>
|
||||
)}
|
||||
{list.map((e, i) => (
|
||||
<div className="extra-row" key={i}>
|
||||
<input
|
||||
className="inp extra-k"
|
||||
placeholder="名称"
|
||||
value={e.label || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(ev) => update(i, 'label', ev.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="inp extra-v"
|
||||
placeholder="值"
|
||||
value={e.value || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(ev) => update(i, 'value', ev.target.value)}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" className="extra-del" onClick={() => remove(i)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button type="button" className="extras-add" onClick={add}>+ 添加一条</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import StatusDot from './StatusDot.jsx'
|
||||
const MAX_SUB = 3
|
||||
|
||||
export default function TaskCard({
|
||||
task, bindDrag, onClick, onToggleSub, onToggle, onAddSub, dragging = false, highlight = false,
|
||||
task, bindDrag, onClick, onToggleSub, onToggle, onMore, dragging = false, highlight = false,
|
||||
}) {
|
||||
const children = task.children || []
|
||||
const shown = children.slice(0, MAX_SUB)
|
||||
@@ -38,6 +38,12 @@ export default function TaskCard({
|
||||
{task.members?.length > 0 && (
|
||||
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
{onMore && (
|
||||
<span
|
||||
className="bmore-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onMore(task) }}
|
||||
>⋯</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shown.map((c) => (
|
||||
@@ -52,15 +58,6 @@ export default function TaskCard({
|
||||
</div>
|
||||
))}
|
||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||
|
||||
{onAddSub && (
|
||||
<div
|
||||
className="bsub-add"
|
||||
onClick={(e) => { e.stopPropagation(); onAddSub(task) }}
|
||||
>
|
||||
+ 子任务
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1143,3 +1143,16 @@ textarea.inp { resize: vertical; }
|
||||
font-size: 11.5px; color: #999; margin-top: 3px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- 通用分段控件 (状态选择等): 当前项高亮 ----
|
||||
此前只有 .pv-head .seg 有定义, 详情弹层里裸用 .seg 会堆成三行 */
|
||||
.seg { display: flex; background: #f2f3f5; border-radius: 8px; padding: 3px; }
|
||||
.seg div { flex: 1; text-align: center; font-size: 13px; padding: 6px 0; border-radius: 6px; color: var(--color-muted); cursor: pointer; }
|
||||
.seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||
|
||||
/* ---- 看板卡片 ⋯ 菜单入口 ---- */
|
||||
.bmore-btn { margin-left: auto; color: #c2c7cf; font-size: 14px; line-height: 1; padding: 2px 4px; cursor: pointer; }
|
||||
|
||||
/* ---- ActionSheet 标题 (任务 ⋯ 菜单) ---- */
|
||||
.as-extra { padding: 4px 0 8px; }
|
||||
.as-title { font-size: 13px; font-weight: 600; color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
Reference in New Issue
Block a user