feat(V0.2): 任务模块界面调整 —— 首屏/项目管理/底部安全距离
Docker Build and Push / build-image (push) Successful in 4m8s
Docker Build and Push / build-image (push) Successful in 4m8s
按 2026-10-24 评审的 5 条调整实现 (需求 T-VW-08~10 / T-PJ-11/12 / T-UI-11): 首屏 (T-VW-08/09): - /task 改为「上=最近项目条(3 个+更多入口), 下=当前项目任务管理」 - 完整项目列表迁至 /task/list, 从「更多项目」进入; /task/:pid 保留供深链 最近项目排序 (T-VW-10): - 卡片长按 300ms 拖动换位, 结果存设备端 prefs.recentOrder - 顺序 = 手动排序 > 最近访问(recentIds) > 列表序, 归档项目不展示 项目管理 (T-PJ-11/12): - 新增 ProjectManage: 成员 / 看板列 / 项目信息 三分区统一入口 - 项目信息含 改名 / 归档切换 / 删除(个人项目隐藏) / 退出(仅创建人) - 项目页头部只留「项目管理 + 新增」, 移除散置的成员/列设置按钮 底部安全距离 (T-UI-11): - 任务详情 / 人员选择 / 拷贝弹层 padding-bottom = 安全区 + 20px - 小屏下弹层 maxHeight + 内部滚动, 底部按钮完整可见 已验证: 97 文件语法 0 错误、相对导入 0 缺失
This commit is contained in:
+2
-1
@@ -37,8 +37,9 @@ export default function App() {
|
||||
{/* 打卡为默认首页 */}
|
||||
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
||||
<Route path="/checkin" element={<CheckinPage />} />
|
||||
{/* 任务模块 (V0.2): 项目列表 / 项目内列表+看板 (T-PM-06) */}
|
||||
{/* 任务模块 (V0.2): 首屏(最近项目+当前项目) / 项目列表 / 深链 (T-VW-08/09) */}
|
||||
<Route path="/task" element={<TaskPage />} />
|
||||
<Route path="/task/list" element={<TaskPage />} />
|
||||
<Route path="/task/:pid" element={<TaskPage />} />
|
||||
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
||||
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { getRecentIds, getRecentOrder, setRecentOrder } from '../../pages/task/prefs.js'
|
||||
|
||||
// 首屏最近项目条 (T-VW-08/10)
|
||||
// - 展示最近使用的 3 个项目 + 「更多项目」入口
|
||||
// - 顺序: 手动排序(recentOrder)优先, 其次最近访问(recentIds), 兜底列表顺序
|
||||
// - 长按卡片 300ms 进入拖拽态, 水平拖动实时换位, 松手持久化(设备端)
|
||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
||||
const colorOf = (id) => COLORS[Math.abs(id) % COLORS.length]
|
||||
const LONG_PRESS = 300
|
||||
const MOVE_TOLERANCE = 8
|
||||
|
||||
export default function RecentProjects({ projects, currentId, onSelect, onMore }) {
|
||||
const [ver, setVer] = useState(0) // 手动排序后强制重算
|
||||
const [dragIdx, setDragIdx] = useState(-1) // 拖拽中的卡片下标
|
||||
const barRef = useRef(null)
|
||||
const timer = useRef(null)
|
||||
const start = useRef(null)
|
||||
const active = useRef(false)
|
||||
|
||||
// 展示顺序: 手动 > 自动 > 列表序, 只取未归档的前 3
|
||||
const shown = useMemo(() => {
|
||||
if (!projects) return []
|
||||
const score = new Map()
|
||||
getRecentOrder().forEach((id, i) => score.set(id, i))
|
||||
let n = score.size
|
||||
getRecentIds().forEach((id) => { if (!score.has(id)) score.set(id, n++) })
|
||||
const fallback = n + 1000
|
||||
return [...projects]
|
||||
.filter((p) => !p.archived)
|
||||
.sort((a, b) => (score.get(a.id) ?? fallback) - (score.get(b.id) ?? fallback))
|
||||
.slice(0, 3)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projects, ver])
|
||||
|
||||
useEffect(() => () => cleanup(), [])
|
||||
|
||||
function cleanup() {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||
active.current = false
|
||||
start.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 }
|
||||
}
|
||||
|
||||
// 按横坐标计算落点下标 (卡片宽 96 + 间距 8)
|
||||
function indexAt(x) {
|
||||
const rect = barRef.current?.getBoundingClientRect()
|
||||
if (!rect) return dragIdx
|
||||
const rel = x - rect.left + 4
|
||||
return Math.max(0, Math.min(shown.length - 1, Math.floor(rel / 104)))
|
||||
}
|
||||
|
||||
function onMove(ev) {
|
||||
const p = pointOf(ev)
|
||||
if (!active.current) {
|
||||
// 未激活前: 移动超容差则取消长按 (用户想滚动)
|
||||
if (start.current && (Math.abs(p.x - start.current.x) > MOVE_TOLERANCE || Math.abs(p.y - start.current.y) > MOVE_TOLERANCE)) {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev.cancelable) ev.preventDefault()
|
||||
const to = indexAt(p.x)
|
||||
if (to !== dragIdx && to >= 0) {
|
||||
// 实时换位: 重写设备端顺序, ver 触发重算 (shown 由 memo 派生)
|
||||
const ids = shown.map((p2) => p2.id)
|
||||
const [moved] = ids.splice(dragIdx, 1)
|
||||
ids.splice(to, 0, moved)
|
||||
setRecentOrder(ids)
|
||||
setDragIdx(to)
|
||||
setVer((v) => v + 1)
|
||||
}
|
||||
}
|
||||
|
||||
function onEnd() {
|
||||
if (active.current) setVer((v) => v + 1)
|
||||
cleanup()
|
||||
setDragIdx(-1)
|
||||
}
|
||||
|
||||
function beginPress(i, ev) {
|
||||
if (ev.button && ev.button !== 0) return
|
||||
const p = pointOf(ev)
|
||||
start.current = p
|
||||
active.current = false
|
||||
cleanup()
|
||||
timer.current = setTimeout(() => {
|
||||
active.current = true
|
||||
setDragIdx(i)
|
||||
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)
|
||||
}
|
||||
|
||||
if (!projects) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rp-bar" ref={barRef}>
|
||||
{shown.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`rp-card${p.id === currentId ? ' on' : ''}${i === dragIdx ? ' dragging' : ''}`}
|
||||
data-project-id={p.id}
|
||||
onTouchStart={(ev) => beginPress(i, ev)}
|
||||
onMouseDown={(ev) => beginPress(i, ev)}
|
||||
onClick={() => { if (!active.current && dragIdx < 0) onSelect?.(p.id) }}
|
||||
>
|
||||
<div className="rp-ico" style={{ background: colorOf(p.id) }}>
|
||||
{(p.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<div className="rp-name">{p.name}</div>
|
||||
<div className="rp-meta">{p.taskCount ?? 0} 个任务</div>
|
||||
<div className="rp-grip">⠿</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rp-more" onClick={onMore}>
|
||||
<div className="rp-more-ico">▤</div>
|
||||
更多项目
|
||||
</div>
|
||||
</div>
|
||||
<div className="rp-drag-tip">长按卡片可调整顺序(本机记忆)</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default function UserPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}>
|
||||
<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">{title}</div>
|
||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, Input, Toast } from 'antd-mobile'
|
||||
import { projectApi } from '../../api/task.js'
|
||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||
import ColumnSettings from './ColumnSettings.jsx'
|
||||
import ProjectMembers from './ProjectMembers.jsx'
|
||||
|
||||
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
|
||||
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
|
||||
export default function ProjectManage({ project, onClose, onChanged, onExited }) {
|
||||
const { user } = useAuth()
|
||||
const [view, setView] = useState('main') // main | columns | members
|
||||
const [renaming, setRenaming] = useState(false)
|
||||
const [name, setName] = useState(project.name)
|
||||
|
||||
const members = project.members || []
|
||||
const isOwner = user?.id === project.ownerId
|
||||
const isPersonal = !!project.isPersonal
|
||||
|
||||
if (view === 'columns') {
|
||||
return (
|
||||
<ColumnSettings
|
||||
projectId={project.id}
|
||||
columns={project.columns || []}
|
||||
onClose={() => setView('main')}
|
||||
onSaved={() => { setView('main'); onChanged?.() }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (view === 'members') {
|
||||
return (
|
||||
<ProjectMembers
|
||||
project={project}
|
||||
onClose={() => setView('main')}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
const t = name.trim()
|
||||
if (!t) { Toast.show({ content: '项目名称不能为空' }); return }
|
||||
try {
|
||||
await projectApi.update(project.id, { name: t })
|
||||
Toast.show({ content: '已改名' })
|
||||
setRenaming(false)
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchive() {
|
||||
const toArchive = !project.archived
|
||||
const ok = await Dialog.confirm({
|
||||
content: toArchive ? '归档该项目?归档后从进行中列表移出,可在归档区找回。' : '取消归档该项目?',
|
||||
confirmText: toArchive ? '归档' : '取消归档',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await projectApi.update(project.id, { archived: toArchive })
|
||||
Toast.show({ content: toArchive ? '已归档' : '已恢复' })
|
||||
if (toArchive) onExited?.()
|
||||
else onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const ok = await Dialog.confirm({
|
||||
content: `删除项目「${project.name}」?其中的任务、看板列将一并删除,不可恢复。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await projectApi.remove(project.id)
|
||||
Toast.show({ content: '已删除' })
|
||||
onExited?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function quit() {
|
||||
const ok = await Dialog.confirm({
|
||||
content: '退出该项目?退出后将不再可见。',
|
||||
confirmText: '退出',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await projectApi.removeMember(project.id, user.id)
|
||||
Toast.show({ content: '已退出' })
|
||||
onExited?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cs-page">
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onClose}>‹</span>
|
||||
<span className="title">项目管理 · {project.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="cs-body">
|
||||
<div className="set-hint" style={{ paddingTop: 12 }}>
|
||||
项目内所有成员权限均等、全读写;以下配置对全体成员即时生效。
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '6px 14px 8px' }}>成员({members.length})</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('members')}>
|
||||
<div className="avatar">{(members[0]?.name || '?').slice(0, 1)}</div>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
|
||||
{members.slice(0, 3).map((m) => m.name).join('、')}{members.length > 3 ? ` 等 ${members.length} 人` : ''}
|
||||
</span>
|
||||
<span className="link">管理 ›</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>看板列</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('columns')}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13, color: '#999' }}>
|
||||
{(project.columns || []).map((c) => c.name).join(' · ')}
|
||||
</span>
|
||||
<span className="link">列设置 ›</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
|
||||
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
|
||||
</div>
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={toggleArchive}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>归档项目</span>
|
||||
<span className="link">{project.archived ? '取消归档' : '归档'}</span>
|
||||
</div>
|
||||
{!isPersonal && (
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={remove}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>删除项目</span>
|
||||
<span className="link danger">删除</span>
|
||||
</div>
|
||||
)}
|
||||
{isOwner && !isPersonal && (
|
||||
<div className="set-row" style={{ padding: '12px 14px' }} onClick={quit}>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>退出项目</span>
|
||||
<span className="link danger">退出</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="set-hint">
|
||||
个人默认项目不可删除、不可退出(T-PJ-02);退出项目仅创建人可见(T-PJ-07)。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
visible={renaming}
|
||||
title="项目名称"
|
||||
content={
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
{ key: 'c', text: '取消', onClick: () => setRenaming(false) },
|
||||
{ key: 'ok', text: '保存', bold: true, onClick: saveName },
|
||||
]}
|
||||
onClose={() => setRenaming(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,20 +5,19 @@ import { filterVisible, msToNextBoundary } from './date.js'
|
||||
import { getViewMode, setViewMode } from './prefs.js'
|
||||
import ListView from './ListView.jsx'
|
||||
import BoardView from './BoardView.jsx'
|
||||
import ColumnSettings from './ColumnSettings.jsx'
|
||||
import TaskDetail from './TaskDetail.jsx'
|
||||
import ProjectMembers from './ProjectMembers.jsx'
|
||||
import ProjectManage from './ProjectManage.jsx'
|
||||
|
||||
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
|
||||
export default function ProjectView({ projectId, onBack }) {
|
||||
// 嵌在任务首屏下部 (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 [showColumns, setShowColumns] = useState(false)
|
||||
const [showMembers, setShowMembers] = useState(false)
|
||||
const [showManage, setShowManage] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [tick, setTick] = useState(0) // 保留窗口重算触发
|
||||
|
||||
@@ -139,12 +138,8 @@ export default function ProjectView({ projectId, onBack }) {
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onBack}>‹</span>
|
||||
<span className="title">任务</span>
|
||||
</div>
|
||||
<div className="empty">
|
||||
<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>
|
||||
@@ -155,24 +150,21 @@ export default function ProjectView({ projectId, onBack }) {
|
||||
|
||||
if (!project || !tasks) {
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onBack}>‹</span>
|
||||
<span className="title">任务</span>
|
||||
<div className="pv-embed">
|
||||
<div className="empty" style={{ padding: '40px 20px' }}>
|
||||
<div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" />
|
||||
</div>
|
||||
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onBack}>‹</span>
|
||||
<div className="pv-embed">
|
||||
<div className="navbar-sub task-projbar">
|
||||
<span className="title">{project.name}</span>
|
||||
<div className="right">
|
||||
<span className="mini-btn" onClick={() => setShowMembers(true)}>成员</span>
|
||||
<span className="mini-btn" onClick={() => setAdding((v) => !v)}>+ 新增</span>
|
||||
<span className="mini-btn" onClick={() => setShowManage(true)}>项目管理</span>
|
||||
<span className="mini-btn solid" onClick={() => setAdding((v) => !v)}>+ 新增</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -182,9 +174,6 @@ export default function ProjectView({ projectId, onBack }) {
|
||||
<div className={mode === 'list' ? 'on' : ''} onClick={() => switchMode('list')}>列表</div>
|
||||
<div className={mode === 'board' ? 'on' : ''} onClick={() => switchMode('board')}>看板</div>
|
||||
</div>
|
||||
<div className="right">
|
||||
<span className="mini-btn grey" onClick={() => setShowColumns(true)}>列设置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
@@ -234,20 +223,12 @@ export default function ProjectView({ projectId, onBack }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showColumns && (
|
||||
<ColumnSettings
|
||||
projectId={projectId}
|
||||
columns={columns}
|
||||
onClose={() => setShowColumns(false)}
|
||||
onSaved={() => { setShowColumns(false); load() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMembers && (
|
||||
<ProjectMembers
|
||||
{showManage && (
|
||||
<ProjectManage
|
||||
project={project}
|
||||
onClose={() => setShowMembers(false)}
|
||||
onChanged={load}
|
||||
onClose={() => setShowManage(false)}
|
||||
onChanged={() => { load(); onProjectChanged?.() }}
|
||||
onExited={() => { setShowManage(false); onProjectExited?.() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
|
||||
visible
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px 14px' }}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px)' }}
|
||||
>
|
||||
<div className="sheet-title">{task.title}</div>
|
||||
<div className="sheet-sub">
|
||||
@@ -253,7 +253,7 @@ export default function TaskDetail({ task, columns, members, onClose, onSaved, o
|
||||
<Popup
|
||||
visible={!!copies}
|
||||
onMaskClick={() => setCopies(null)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}
|
||||
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">源任务保持不变;新任务的创建人是你</div>
|
||||
|
||||
+93
-42
@@ -1,19 +1,26 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Dialog, DotLoading, Empty, Input, Toast } from 'antd-mobile'
|
||||
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, prunePrefs } from './prefs.js'
|
||||
import {
|
||||
getLastProjectId, setLastProjectId, setRecentIds, prunePrefs,
|
||||
} from './prefs.js'
|
||||
import RecentProjects from '../../components/task/RecentProjects.jsx'
|
||||
import ProjectView from './ProjectView.jsx'
|
||||
|
||||
// 任务模块容器 (T-PM-06)
|
||||
// 无 :pid -> 项目列表
|
||||
// 有 :pid -> 该项目视图(列表/看板)
|
||||
// 任务模块容器 (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 load = useCallback(() => {
|
||||
setErr('')
|
||||
@@ -28,34 +35,91 @@ export default function TaskPage() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// 记住上次访问的项目 (T-VW-07)
|
||||
// 决定当前项目: 深链 pid > 上次访问 > 个人默认项目 (T-VW-09)
|
||||
useEffect(() => {
|
||||
if (pid) setLastProjectId(Number(pid))
|
||||
}, [pid])
|
||||
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])
|
||||
|
||||
if (pid) {
|
||||
return <ProjectView projectId={Number(pid)} onBack={() => navigate('/task')} />
|
||||
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>
|
||||
<div className="task-content">
|
||||
<ProjectList
|
||||
projects={projects}
|
||||
err={err}
|
||||
onRetry={load}
|
||||
onOpen={(id) => { selectProject(id); navigate('/task') }}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<PageHeader title="任务" />
|
||||
<div className="task-content">
|
||||
<ProjectList
|
||||
projects={projects}
|
||||
err={err}
|
||||
onRetry={load}
|
||||
onOpen={(id) => {
|
||||
setLastProjectId(id)
|
||||
navigate(`/task/${id}`)
|
||||
}}
|
||||
onChanged={load}
|
||||
/>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// 完整项目列表 (从「更多项目」进入)
|
||||
function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
@@ -71,9 +135,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
)
|
||||
}
|
||||
if (!projects) {
|
||||
return (
|
||||
<div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
|
||||
)
|
||||
return <div className="empty"><div className="ico">··</div><p>加载中…</p><DotLoading color="#1677ff" /></div>
|
||||
}
|
||||
|
||||
const active = projects.filter((p) => !p.archived)
|
||||
@@ -84,11 +146,12 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
await projectApi.create({ name: t })
|
||||
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 {
|
||||
@@ -101,13 +164,7 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="proj-head">
|
||||
<b>我的项目</b>
|
||||
<div className="right">
|
||||
<div className="mini-btn" onClick={() => setCreating(true)}>+ 新建</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 8 }} />
|
||||
{active.map((p) => (
|
||||
<div className="proj-item" key={p.id} onClick={() => onOpen(p.id)}>
|
||||
<div className="pico" style={{ background: colorOf(p) }}>
|
||||
@@ -150,17 +207,14 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ height: 24 }} />
|
||||
|
||||
<Dialog
|
||||
visible={creating}
|
||||
title="新建项目"
|
||||
content={
|
||||
<div className="field" style={{ marginTop: 8 }}>
|
||||
<Input
|
||||
placeholder="项目名称"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
maxLength={20}
|
||||
/>
|
||||
<Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
@@ -172,6 +226,3 @@ function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 避免未使用告警
|
||||
void Empty
|
||||
|
||||
+48
-9
@@ -1,19 +1,27 @@
|
||||
// 任务模块: 设备端偏好 (不落库, 不跨设备)
|
||||
// lastProjectId 上次访问的项目 (T-VW-07)
|
||||
// viewMode 按项目记忆的视图 list | board (T-VW-03/06)
|
||||
// recentIds 自动按最近访问排序的项目 id (最前 = 最近), 上限 10 (T-VW-08)
|
||||
// recentOrder 首屏最近项目卡片的手动排序结果, 优先于 recentIds (T-VW-10)
|
||||
const KEY = 'iboard_task_prefs'
|
||||
|
||||
function blank() {
|
||||
return { lastProjectId: null, viewMode: {}, recentIds: [], recentOrder: [] }
|
||||
}
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return { lastProjectId: null, viewMode: {} }
|
||||
if (!raw) return blank()
|
||||
const obj = JSON.parse(raw)
|
||||
return {
|
||||
lastProjectId: obj.lastProjectId ?? null,
|
||||
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
|
||||
recentIds: Array.isArray(obj.recentIds) ? obj.recentIds : [],
|
||||
recentOrder: Array.isArray(obj.recentOrder) ? obj.recentOrder : [],
|
||||
}
|
||||
} catch {
|
||||
return { lastProjectId: null, viewMode: {} }
|
||||
return blank()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +54,44 @@ export function setViewMode(projectId, mode) {
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 清理已不可见的项目记录 */
|
||||
export function prunePrefs(visibleIds) {
|
||||
const set = new Set((visibleIds || []).map(String))
|
||||
/** 最近访问记录: 把 id 提到队首, 上限 10 (T-VW-08) */
|
||||
export function getRecentIds() {
|
||||
return read().recentIds
|
||||
}
|
||||
|
||||
export function setRecentIds(id) {
|
||||
if (!id) return
|
||||
const cur = read()
|
||||
if (cur.lastProjectId && !set.has(String(cur.lastProjectId))) cur.lastProjectId = null
|
||||
for (const k of Object.keys(cur.viewMode)) {
|
||||
if (!set.has(k)) delete cur.viewMode[k]
|
||||
}
|
||||
cur.recentIds = [id, ...cur.recentIds.filter((x) => x !== id)].slice(0, 10)
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 首屏卡片手动排序 (T-VW-10): 完整顺序, 排在前面的项目优先展示 */
|
||||
export function getRecentOrder() {
|
||||
return read().recentOrder
|
||||
}
|
||||
|
||||
export function setRecentOrder(ids) {
|
||||
const cur = read()
|
||||
cur.recentOrder = (Array.isArray(ids) ? ids : []).map(Number).filter(Boolean)
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 清理已不可见的项目记录 */
|
||||
export function prunePrefs(visibleIds) {
|
||||
const set = new Set((visibleIds || []).map(Number))
|
||||
const cur = read()
|
||||
let dirty = false
|
||||
if (cur.lastProjectId && !set.has(cur.lastProjectId)) { cur.lastProjectId = null; dirty = true }
|
||||
for (const k of Object.keys(cur.viewMode)) {
|
||||
if (!set.has(Number(k))) { delete cur.viewMode[k]; dirty = true }
|
||||
}
|
||||
const ri = cur.recentIds.filter((id) => set.has(id))
|
||||
const ro = cur.recentOrder.filter((id) => set.has(id))
|
||||
if (ri.length !== cur.recentIds.length || ro.length !== cur.recentOrder.length) dirty = true
|
||||
if (dirty) {
|
||||
cur.recentIds = ri
|
||||
cur.recentOrder = ro
|
||||
write(cur)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1096,3 +1096,38 @@ textarea.inp { resize: vertical; }
|
||||
}
|
||||
.empty .ico.err { background: #fff1f0; color: #ff7875; }
|
||||
.empty p { font-size: 13px; margin-bottom: 12px; }
|
||||
|
||||
/* ---- 任务首屏 (T-VW-08/09/10): 最近项目条 + 当前项目 ---- */
|
||||
.task-home { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.rp-bar { display: flex; gap: 8px; padding: 10px 12px 6px; overflow-x: auto; flex-shrink: 0; }
|
||||
.rp-card {
|
||||
flex: 0 0 96px; background: #fff; border-radius: 10px; padding: 9px 10px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer; position: relative;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
.rp-card.on { border-color: var(--color-primary); background: #f5f9ff; }
|
||||
.rp-card.dragging { opacity: .6; transform: scale(.96); box-shadow: 0 8px 18px rgba(15,23,42,.18); z-index: 5; }
|
||||
.rp-card .rp-ico {
|
||||
width: 26px; height: 26px; border-radius: 7px; color: #fff; font-size: 11px;
|
||||
font-weight: 700; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.rp-card .rp-name { font-size: 12px; font-weight: 600; margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rp-card .rp-meta { font-size: 9.5px; color: #999; margin-top: 1px; }
|
||||
.rp-card .rp-grip { position: absolute; top: 5px; right: 6px; color: #d3d8de; font-size: 10px; letter-spacing: -1px; }
|
||||
.rp-more {
|
||||
flex: 0 0 auto; align-self: stretch; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 3px; color: #666; font-size: 10.5px; cursor: pointer; padding: 0 6px;
|
||||
}
|
||||
.rp-more .rp-more-ico { width: 30px; height: 30px; border-radius: 50%; background: #f2f3f5; color: #9aa1aa; display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.rp-drag-tip { font-size: 9.5px; color: #b8bcc4; padding: 0 14px 2px; flex-shrink: 0; }
|
||||
|
||||
/* 首屏内嵌的项目视图 (T-VW-09) */
|
||||
.pv-embed { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.task-projbar {
|
||||
display: flex; align-items: center; gap: 10px; padding: 9px 14px;
|
||||
background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; margin-top: 4px;
|
||||
}
|
||||
.task-projbar .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
||||
.task-projbar .title { font-size: 15px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.task-projbar .right { margin-left: auto; display: flex; gap: 6px; flex-shrink: 0; }
|
||||
.pv-embed .task-content { flex: 1 1 auto; }
|
||||
|
||||
Reference in New Issue
Block a user