Files
iboard/src/pages/task/TaskPage.jsx
T
jif2.zhang b71ceca3f2
Docker Build and Push / build-image (push) Successful in 4m19s
fix(V0.2): BUILD_ID 时区 / 新建弹框残留 / 项目信息块
1. BUILD_ID 改用 UTC+8 生成: CI runner 是 UTC 时区, 原来取本地时间会错位;
   统一偏移 +8h 后按 UTC 读取 (vite.config.js makeBuildId)

2. 修复「我的项目」页反复弹新建框: createSignal 一次性消费,
   ProjectList 处理后回调清零 (此前 TaskPage 状态在 /task 与 /task/list
   间保留, 信号残留导致每次进入都触发)

3. 新增项目信息块 (T-PJ-13):
   - Project.notes Json ([{title,content}], <=50 块 / 标题<=50 字 / 内容<=20000 字)
   - 迁移 20261024130000: 加列(NULL)->回填'[]'->收紧 NOT NULL, 存量行安全
   - GET 详情返回 notes; PUT 接受 notes 整体替换
   - 项目管理新增「信息块」分区: 列表预览 + 底部弹层独立编辑(标题+多行
     textarea+删除), 键盘弹出复用 useKeyboardInset 抬升
   - 文档: 需求 T-PJ-13 (149 条) + 功能设计对应行

已验证: 109 文件语法 0 错误、文档 149/149 覆盖
2026-10-24 21:37:05 +08:00

348 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { Dialog, DotLoading, Input, Toast } from 'antd-mobile'
import PageHeader from '../../components/PageHeader.jsx'
import { projectApi } from '../../api/task.js'
import {
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
getRecentIds, getRecentOrder, setRecentOrder,
} from './prefs.js'
import RecentProjects from '../../components/task/RecentProjects.jsx'
import ProjectView from './ProjectView.jsx'
// 任务模块容器 (T-PM-06, T-VW-08/09)
// /task 首屏: 上 = 最近项目条(3 个 + 更多入口), 下 = 当前项目的任务管理
// /task/list 完整项目列表(从「更多项目」进入; 支持拖动排序 + 新建)
// /task/:pid 打开指定项目(深链), 布局同首屏
export default function TaskPage() {
const { pid } = useParams()
const { pathname } = useLocation()
const navigate = useNavigate()
const isList = pathname === '/task/list'
const [projects, setProjects] = useState(null)
const [err, setErr] = useState('')
const [currentId, setCurrentId] = useState(null)
const [createSignal, setCreateSignal] = useState(0) // 通知项目列表弹起新建对话框
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])
// 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09)
useEffect(() => {
if (!projects) return
const ok = (x) => x && projects.some((p) => p.id === x && !p.archived)
let id = pid ? Number(pid) : getLastProjectId()
if (!ok(id)) id = projects.find((p) => p.isPersonal)?.id ?? projects[0]?.id ?? null
if (!ok(id)) id = null
setCurrentId(id)
if (id) { setLastProjectId(id); setRecentIds(id) }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, pid])
const selectProject = (id) => {
setCurrentId(id)
setLastProjectId(id)
setRecentIds(id)
if (pid) navigate('/task', { replace: true })
}
// 项目被删除 / 退出 / 归档后: 重载并回落个人默认项目
const handleExited = () => {
setCurrentId(null)
load()
}
if (isList) {
return (
<div className="page-with-navbar">
<div className="navbar-sub task-projbar">
<span className="back" onClick={() => navigate('/task')}>‹</span>
<span className="title">我的项目</span>
<div className="right">
<span className="mini-btn solid" onClick={() => setCreateSignal((v) => v + 1)}>+ 新建</span>
</div>
</div>
<div className="task-content">
<ProjectList
projects={projects}
err={err}
onRetry={load}
onOpen={(id) => { selectProject(id); navigate('/task') }}
onChanged={load}
createSignal={createSignal}
onCreateConsumed={() => setCreateSignal(0)}
/>
</div>
</div>
)
}
return (
<div className="page-with-navbar">
<PageHeader title="任务" />
<div className="task-home">
{err && !projects ? (
<div className="empty">
<div className="ico err">!</div>
<p>{err}</p>
<div className="mini-btn solid" onClick={load}>重试</div>
</div>
) : !projects ? (
<div className="empty" style={{ padding: '40px 20px' }}>
<div className="ico">··</div>
<p>加载中…</p>
<DotLoading color="#1677ff" />
</div>
) : (
<>
<RecentProjects
projects={projects}
currentId={currentId}
onSelect={selectProject}
onMore={() => navigate('/task/list')}
/>
{currentId != null && (
<ProjectView
key={currentId}
projectId={currentId}
onProjectChanged={load}
onProjectExited={handleExited}
/>
)}
</>
)}
</div>
</div>
)
}
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
const LONG_PRESS = 300
const MOVE_TOLERANCE = 8
// 完整项目列表 (从「更多项目」进入)
// 顺序: 手动排序(recentOrder, 拖动结果)优先 > 最近访问 > 列表序; 已归档固定在底部
function ProjectList({ projects, err, onRetry, onOpen, onChanged, createSignal, onCreateConsumed }) {
const [creating, setCreating] = useState(false)
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
const [ver, setVer] = useState(0) // 拖动排序后强制重算
const [dragId, setDragId] = useState(null) // 拖拽中的项目 id (视觉态)
const listRef = useRef(null)
const timer = useRef(null)
const startRef = useRef(null)
const activeRef = useRef(false)
const dragIdRef = useRef(null)
// 头部「+ 新建」→ 弹起新建对话框 (一次性: 消费后清零, 否则下次进入该页会再次弹出)
useEffect(() => {
if (createSignal) {
setCreating(true)
onCreateConsumed?.()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [createSignal])
const active = useMemo(() => {
const manual = getRecentOrder()
const score = new Map(manual.map((id, i) => [id, i]))
let n = score.size
getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) })
const fb = n + 1000
return projects
.filter((p) => !p.archived)
.slice()
.sort((a, b) => (score.get(a.id) ?? fb) - (score.get(b.id) ?? fb))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [projects, ver])
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 archived = projects.filter((p) => p.archived)
async function create() {
const t = name.trim()
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
setSaving(true)
try {
const r = await projectApi.create({ name: t })
setName('')
setCreating(false)
Toast.show({ content: '已创建' })
onChanged?.()
onOpen?.(r.item.id)
} catch (e) {
Toast.show({ content: e.message })
} finally {
setSaving(false)
}
}
// ---- 长按拖动排序 (纵向): 复用首屏卡片同一套 prefs.recentOrder ----
function cleanup() {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
activeRef.current = false
startRef.current = null
dragIdRef.current = null
document.removeEventListener('touchmove', onMove, { passive: false })
document.removeEventListener('touchend', onEnd)
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onEnd)
}
const pointOf = (ev) => {
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
return { x: t.clientX, y: t.clientY }
}
function indexAt(y) {
const items = Array.from(listRef.current?.querySelectorAll('[data-pid]') || [])
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect()
if (y < r.top + r.height / 2) return i
}
return Math.max(0, items.length - 1)
}
function onMove(ev) {
const p = pointOf(ev)
if (!activeRef.current) {
if (startRef.current && (Math.abs(p.x - startRef.current.x) > MOVE_TOLERANCE || Math.abs(p.y - startRef.current.y) > MOVE_TOLERANCE)) {
if (timer.current) { clearTimeout(timer.current); timer.current = null }
}
return
}
if (ev.cancelable) ev.preventDefault()
const ids = active.map((x) => x.id)
const from = ids.indexOf(dragIdRef.current)
if (from < 0) return
const to = indexAt(p.y)
if (to !== from) {
const [moved] = ids.splice(from, 1)
ids.splice(to, 0, moved)
setRecentOrder(ids)
setVer((v) => v + 1)
}
}
function onEnd() {
cleanup()
setDragId(null)
}
function beginPress(p, ev) {
if (ev.button && ev.button !== 0) return
const pt = pointOf(ev)
startRef.current = pt
activeRef.current = false
cleanup()
dragIdRef.current = p.id
timer.current = setTimeout(() => {
activeRef.current = true
setDragId(p.id)
if (navigator.vibrate) navigator.vibrate(10)
}, LONG_PRESS)
document.addEventListener('touchmove', onMove, { passive: false })
document.addEventListener('touchend', onEnd)
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onEnd)
}
useEffect(() => () => cleanup(), [])
return (
<div ref={listRef}>
<div className="rp-drag-tip" style={{ padding: '8px 14px 6px' }}>
长按项目可拖动排序(本机记忆,与首屏最近项目一致)
</div>
{active.map((p) => (
<div
className={`proj-item${p.id === dragId ? ' dragging' : ''}`}
key={p.id}
data-pid={p.id}
onTouchStart={(ev) => beginPress(p, ev)}
onMouseDown={(ev) => beginPress(p, ev)}
onClick={() => { if (!activeRef.current && !dragId) 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>
))}
</>
)}
<div style={{ height: 24 }} />
<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)}
/>
</div>
)
}