Docker Build and Push / build-image (push) Successful in 1m22s
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭, 焦点离开且无内容时自动收起 - 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单 - Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片, 客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位 显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
315 lines
11 KiB
React
315 lines
11 KiB
React
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
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'
|
|
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
|
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)
|
|
export default function ProjectView({ projectId, onProjectChanged, onProjectExited }) {
|
|
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 [showManage, setShowManage] = useState(false)
|
|
const [adding, setAdding] = useState(false)
|
|
const [tick, setTick] = useState(0) // 保留窗口重算触发
|
|
const [copyTask, setCopyTask] = useState(null) // ⋯菜单触发的跨项目拷贝
|
|
|
|
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 { /* 已回滚并提示 */ }
|
|
}
|
|
|
|
/** 组内拖动排序: 按分组顺序展平重写 sortOrder (只动顶层任务) */
|
|
const reorderTask = async (task, targetId, after) => {
|
|
const snapshot = tasks
|
|
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
|
const groups = orderedCols.map((c) => (tasks || []).filter((t) => t.columnId === c.id)).filter((l) => l.length)
|
|
const g = groups.find((l) => l.some((t) => t.id === task.id))
|
|
if (!g) return
|
|
const rest = g.filter((t) => t.id !== task.id)
|
|
const ti = rest.findIndex((t) => t.id === targetId)
|
|
if (ti < 0) return
|
|
rest.splice(after ? ti + 1 : ti, 0, task)
|
|
const flat = groups.map((l) => (l === g ? rest : l)).flat()
|
|
const flatIds = new Set(flat.map((t) => t.id))
|
|
// 乐观更新必须重排数组本身 —— 列表分组渲染按数组顺序,
|
|
// 只改 sortOrder 字段界面不会动 (打卡侧即按此口径)
|
|
setTasks((prev) => [
|
|
...flat.map((t, i) => ({ ...t, sortOrder: i })),
|
|
...(prev || []).filter((t) => !flatIds.has(t.id)),
|
|
])
|
|
try {
|
|
await taskApi.reorder(projectId, flat.map((t) => t.id))
|
|
} catch (e) {
|
|
setTasks(snapshot)
|
|
Toast.show({ content: e.message || '排序保存失败' })
|
|
}
|
|
}
|
|
|
|
/** 改状态 (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 */ }
|
|
}
|
|
|
|
/** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
|
|
* 前段为标题, 余文并入描述 (T-TK-14/16) */
|
|
async function createTask(rawTitle, parentId = null) {
|
|
const raw = String(rawTitle || '').trim()
|
|
if (!raw) return
|
|
const { markDate, rest } = parseTimePrefix(raw)
|
|
const { title, desc } = splitTitle(rest)
|
|
if (!title) return
|
|
try {
|
|
await taskApi.create(projectId, {
|
|
title,
|
|
description: desc || undefined,
|
|
markDate: markDate || undefined,
|
|
parentId,
|
|
})
|
|
setAdding(false)
|
|
load()
|
|
} catch (e) {
|
|
Toast.show({ content: e.message })
|
|
}
|
|
}
|
|
|
|
/** 任务 ⋯ 菜单: 编辑 / 拷贝到其他项目 / 删除 (点选后菜单收回) */
|
|
function openTaskMenu(task) {
|
|
const handler = ActionSheet.show({
|
|
actions: [
|
|
{ text: '编辑', key: 'edit' },
|
|
{ text: '拷贝到其他项目', key: 'copy' },
|
|
{ text: '删除', key: 'delete', bold: true, danger: true },
|
|
],
|
|
cancelText: '取消',
|
|
onAction: async (item) => {
|
|
handler.close?.()
|
|
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">
|
|
<div className="empty" style={{ paddingTop: 40 }}>
|
|
<div className="ico err">!</div>
|
|
<p>{err}</p>
|
|
<div className="mini-btn solid" onClick={load}>重试</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!project || !tasks) {
|
|
return (
|
|
<div className="pv-embed">
|
|
<div className="empty" style={{ padding: '40px 20px' }}>
|
|
<div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="pv-embed">
|
|
<div className="navbar-sub task-projbar">
|
|
<span className="title">{project.name}</span>
|
|
<div className="right">
|
|
<span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
|
|
<span className="mini-btn solid" 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>
|
|
|
|
{adding && (
|
|
<div className="quick-add">
|
|
<textarea
|
|
className="inp"
|
|
autoFocus
|
|
rows={3}
|
|
maxLength={200}
|
|
placeholder={'输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词\n超过 30 字自动截断为标题,全文进描述'}
|
|
onBlur={(e) => { if (!e.currentTarget.value.trim()) setAdding(false) }}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); createTask(e.currentTarget.value) }
|
|
if (e.key === 'Escape') setAdding(false)
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mode === 'list' ? (
|
|
<ListView
|
|
tasks={visibleTasks}
|
|
columns={columns}
|
|
onToggle={toggleDone}
|
|
onOpen={(t) => setOpenTask(t)}
|
|
onMore={openTaskMenu}
|
|
onMoveColumn={moveToColumn}
|
|
onReorder={reorderTask}
|
|
/>
|
|
) : (
|
|
<BoardView
|
|
tasks={visibleTasks}
|
|
columns={columns}
|
|
onMoveColumn={moveToColumn}
|
|
onOpen={(t) => setOpenTask(t)}
|
|
onToggleSub={(c) => toggleDone(c)}
|
|
onToggle={(t) => toggleDone(t)}
|
|
onMore={openTaskMenu}
|
|
onQuickAdd={() => { setAdding(true) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{openTask && (
|
|
<TaskDetail
|
|
task={openTask}
|
|
columns={columns}
|
|
members={project.members || []}
|
|
tasks={tasks || []}
|
|
taskKinds={project.taskKinds}
|
|
onClose={() => setOpenTask(null)}
|
|
onSaved={() => { setOpenTask(null); load() }}
|
|
onChanged={load}
|
|
/>
|
|
)}
|
|
|
|
{copyTask && (
|
|
<CopyTaskPopup
|
|
task={copyTask}
|
|
visible
|
|
onClose={() => setCopyTask(null)}
|
|
onDone={load}
|
|
/>
|
|
)}
|
|
|
|
{showManage && (
|
|
<ProjectManage
|
|
project={project}
|
|
onClose={() => setShowManage(false)}
|
|
onChanged={() => { load(); onProjectChanged?.() }}
|
|
onExited={() => { setShowManage(false); onProjectExited?.() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|