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 (
{err}
加载中…