Docker Build and Push / build-image (push) Successful in 4m19s
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 覆盖
348 lines
12 KiB
React
348 lines
12 KiB
React
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>
|
||
)
|
||
}
|