feat(V0.2): 任务模块 —— 项目 / 列表与看板 / 列-状态绑定 / 一级子任务
Docker Build and Push / build-image (push) Failing after 3m51s
Docker Build and Push / build-image (push) Failing after 3m51s
需求 142 条 (T-*), 设计见 docs/V0.2/任务模块/ 数据层: - 新增 6 张表: Project / ProjectMember / BoardColumn / Task / TaskMember / TaskTag - 看板列是实体(可增删改排序), Task.columnId 外键 RESTRICT 防误删列丢任务 - Task.status(todo/doing/done) 与 columnId 双落库, 二者正交 领域层 server/task-domain/ (唯一口径, 前后端不重复实现): - resolveStatus: 列决定状态(只看目标列, 无拖出回退); 状态与列不匹配时按列序取第一个绑定该状态的列自动归位, 无匹配列则不移动; 子任务完全豁免 - retention: 逐列 6 档保留窗口(永不显示/当天/3天/本周/本月/永久显示), 以 doneAt 判定 - copyTask: 跨项目深拷贝, 人员按目标项目成员过滤并回报丢弃名单 接口: /api/projects, /api/columns, /api/tasks (均校验项目成员) 前端: - 底部 Tab 插入「任务」, 位于「打卡」之后; 默认首页仍为打卡 - 列表视图(子任务缩进+折叠) / 看板视图(长按拖拽换列、拖到卡片降为子任务) - 列设置(列管理+状态绑定+保留选项) / 任务详情(扩展信息卡片默认收起) / 成员管理 - 视图记忆与上次项目存设备端 已验证: 95 文件语法 0 错误、相对导入 0 缺失、领域逻辑用例全部通过
This commit is contained in:
@@ -6,6 +6,7 @@ import MonitorPage from './pages/MonitorPage.jsx'
|
||||
import ServerDetail from './pages/monitor/ServerDetail.jsx'
|
||||
import MePage from './pages/MePage.jsx'
|
||||
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
||||
import TaskPage from './pages/task/TaskPage.jsx'
|
||||
import { useAuth } from './auth/AuthContext.jsx'
|
||||
|
||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||
@@ -36,6 +37,9 @@ export default function App() {
|
||||
{/* 打卡为默认首页 */}
|
||||
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
||||
<Route path="/checkin" element={<CheckinPage />} />
|
||||
{/* 任务模块 (V0.2): 项目列表 / 项目内列表+看板 (T-PM-06) */}
|
||||
<Route path="/task" element={<TaskPage />} />
|
||||
<Route path="/task/:pid" element={<TaskPage />} />
|
||||
{/* 宏观: 报警 / 新闻 / 油价 / 经济数据 / 策略研究 / 经济文章 (V0.2 原首页并入, 默认新闻) */}
|
||||
<Route path="/macro" element={<Navigate to="/macro/news" replace />} />
|
||||
<Route path="/macro/*" element={<MacroPage />} />
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { api } from './client.js'
|
||||
|
||||
const q = (params) => {
|
||||
const s = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') s.set(k, v)
|
||||
})
|
||||
const str = s.toString()
|
||||
return str ? '?' + str : ''
|
||||
}
|
||||
|
||||
// 项目
|
||||
export const projectApi = {
|
||||
list: () => api('/projects'),
|
||||
detail: (id) => api(`/projects/${id}`),
|
||||
create: (body) => api('/projects', { method: 'POST', body }),
|
||||
update: (id, body) => api(`/projects/${id}`, { method: 'PUT', body }),
|
||||
remove: (id) => api(`/projects/${id}`, { method: 'DELETE' }),
|
||||
addMember: (id, body) => api(`/projects/${id}/members`, { method: 'POST', body }),
|
||||
removeMember: (id, uid) => api(`/projects/${id}/members/${uid}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// 看板列
|
||||
export const columnApi = {
|
||||
list: (projectId) => api(`/projects/${projectId}/columns`),
|
||||
add: (projectId, body) => api(`/projects/${projectId}/columns`, { method: 'POST', body }),
|
||||
update: (id, body) => api(`/columns/${id}`, { method: 'PUT', body }),
|
||||
remove: (id, moveToColumnId) => api(`/columns/${id}${q({ moveToColumnId })}`, { method: 'DELETE' }),
|
||||
order: (projectId, ids) => api(`/projects/${projectId}/columns/order`, { method: 'PUT', body: { ids } }),
|
||||
}
|
||||
|
||||
// 任务
|
||||
export const taskApi = {
|
||||
list: (projectId) => api(`/projects/${projectId}/tasks`),
|
||||
create: (projectId, body) => api(`/projects/${projectId}/tasks`, { method: 'POST', body }),
|
||||
detail: (id) => api(`/tasks/${id}`),
|
||||
update: (id, body) => api(`/tasks/${id}`, { method: 'PUT', body }),
|
||||
remove: (id) => api(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
copy: (id, projectId) => api(`/tasks/${id}/copy`, { method: 'POST', body: { projectId } }),
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import TabIcon from './TabIcon.jsx'
|
||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
||||
// V0.2 任务模块: 「任务」插入到「打卡」之后 (T-PM-06), 默认首页仍为打卡
|
||||
const TABS = [
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/task', name: 'task', label: '任务' },
|
||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||
{ key: '/me', name: 'me', label: '我' },
|
||||
|
||||
@@ -25,6 +25,16 @@ const ICONS = {
|
||||
<path d="M9 15.5l2.2 2.2L15.5 13" />
|
||||
</svg>
|
||||
),
|
||||
// 任务: 清单本 + 勾选行 (V0.2 任务模块)
|
||||
task: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="4" y="4" width="16" height="17" rx="2" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.12 : 0} />
|
||||
<path d="M8 9.5l1.4 1.4L12 8.3" />
|
||||
<path d="M14.5 9.5h3" />
|
||||
<path d="M8 15.5l1.4 1.4L12 14.3" />
|
||||
<path d="M14.5 15.5h3" />
|
||||
</svg>
|
||||
),
|
||||
me: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="8" r="4" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
// 扩展信息卡片 (T-TK-04~07): 任意条目, 默认收起, 全部非必填
|
||||
// 条目结构 { label, value, url? }
|
||||
export default function ExtrasCard({ extras = [], onChange, readOnly = false }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const list = Array.isArray(extras) ? extras : []
|
||||
|
||||
const update = (idx, key, val) => {
|
||||
const next = list.map((e, i) => (i === idx ? { ...e, [key]: val } : e))
|
||||
onChange?.(next)
|
||||
}
|
||||
|
||||
const add = () => onChange?.([...list, { label: '', value: '', url: '' }])
|
||||
const remove = (idx) => onChange?.(list.filter((_, i) => i !== idx))
|
||||
|
||||
return (
|
||||
<div className="extras">
|
||||
<div className="extras-head" onClick={() => setOpen((v) => !v)}>
|
||||
<span className="caret">{open ? '▾' : '▸'}</span>
|
||||
<span>扩展信息</span>
|
||||
{list.length > 0 && <span className="tag">{list.length}</span>}
|
||||
<span className="cv">{open ? '收起' : '默认收起 · 全部非必填'}</span>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="extras-body">
|
||||
{list.length === 0 && (
|
||||
<div className="extras-empty">暂无条目,可记录仓库 / 分支 / 链接等任意信息</div>
|
||||
)}
|
||||
{list.map((e, i) => (
|
||||
<div className="extra-row" key={i}>
|
||||
<input
|
||||
className="inp extra-k"
|
||||
placeholder="名称"
|
||||
value={e.label || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(ev) => update(i, 'label', ev.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="inp extra-v"
|
||||
placeholder="值"
|
||||
value={e.value || ''}
|
||||
disabled={readOnly}
|
||||
onChange={(ev) => update(i, 'value', ev.target.value)}
|
||||
/>
|
||||
{!readOnly && (
|
||||
<button type="button" className="extra-del" onClick={() => remove(i)}>×</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button type="button" className="extras-add" onClick={add}>+ 添加一条</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 任务状态小圆点 / 药丸标记 (T-ST-09)
|
||||
const LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
|
||||
|
||||
export default function StatusDot({ status = 'todo', pill = false, className = '' }) {
|
||||
const cls = `sdot ${status}${pill ? ' pill' : ''} ${className}`.trim()
|
||||
if (pill) return <span className={cls}>{LABEL[status] || ''}</span>
|
||||
return <span className={cls} title={LABEL[status] || ''} />
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import StatusDot from './StatusDot.jsx'
|
||||
|
||||
// 看板卡片 (T-UI-02): 轻量化
|
||||
// - 标题 + 状态点 (T-ST-09)
|
||||
// - 负责人 12px/600 突出 (T-AS-01); 任务成员 10.5px 弱化置于下方 (T-AS-02)
|
||||
// - 子任务内嵌简洁展示 + 可直接勾选 (T-ST-06/07)
|
||||
const MAX_SUB = 3
|
||||
|
||||
export default function TaskCard({
|
||||
task, bindDrag, onClick, onToggleSub, onToggle, onAddSub, dragging = false, highlight = false,
|
||||
}) {
|
||||
const children = task.children || []
|
||||
const shown = children.slice(0, MAX_SUB)
|
||||
const rest = children.length - shown.length
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bcard${dragging ? ' dragging' : ''}${highlight ? ' hl' : ''}`}
|
||||
data-task-id={task.id}
|
||||
onClick={onClick}
|
||||
{...(bindDrag ? bindDrag(task) : {})}
|
||||
>
|
||||
<div className="brow1">
|
||||
<span className="btitle">{task.title}</span>
|
||||
<span
|
||||
className="bstat"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||
title="点按切换完成"
|
||||
>
|
||||
<StatusDot status={task.status} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bmeta">
|
||||
<span className={`bassignee${task.assignee ? '' : ' none'}`}>
|
||||
{task.assignee?.name || '未指派'}
|
||||
</span>
|
||||
{task.members?.length > 0 && (
|
||||
<span className="bmembers">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shown.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`bsub${c.status === 'done' ? ' done' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSub?.(c) }}
|
||||
>
|
||||
<span className="sbox" />
|
||||
<span className="stitle">{c.title}</span>
|
||||
<StatusDot status={c.status} />
|
||||
</div>
|
||||
))}
|
||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
|
||||
|
||||
{onAddSub && (
|
||||
<div
|
||||
className="bsub-add"
|
||||
onClick={(e) => { e.stopPropagation(); onAddSub(task) }}
|
||||
>
|
||||
+ 子任务
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import StatusDot from './StatusDot.jsx'
|
||||
|
||||
// 列表视图的一行 (Todoist 风格, T-UI-01)
|
||||
export default function TaskRow({
|
||||
task, onToggle, onClick, onMore, indent = false, children,
|
||||
}) {
|
||||
const done = task.status === 'done'
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${indent ? 'sub-row' : 'task-row'}${done ? ' done' : ''}`}
|
||||
data-task-id={indent ? undefined : task.id}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className="box"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle?.(task) }}
|
||||
>
|
||||
<svg viewBox="0 0 14 14" width={indent ? 10 : 11} height={indent ? 10 : 11}>
|
||||
<path d="M2.5 7.2l3 3 6-6.4" stroke="#fff" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{indent ? (
|
||||
<>
|
||||
<span className="sub-title">{task.title}</span>
|
||||
<StatusDot status={task.status} pill />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="task-main">
|
||||
<div className="task-title">{task.title}</div>
|
||||
<div className="task-meta">
|
||||
<StatusDot status={task.status} />
|
||||
<span>{task.status === 'done' ? '完成' : task.status === 'doing' ? '执行中' : '未开始'}</span>
|
||||
{task.column?.name && <span className="col">{task.column.name}</span>}
|
||||
<span className={`assignee${task.assignee ? '' : ' none'}`}>
|
||||
{task.assignee?.name || '未指派'}
|
||||
</span>
|
||||
{task.members?.length > 0 && (
|
||||
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-right">
|
||||
<span className="more" onClick={(e) => { e.stopPropagation(); onMore?.(task) }}>⋯</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Popup, Toast } from 'antd-mobile'
|
||||
|
||||
// 人员选择 (T-AS-03/04): 只能从"当前项目成员"中选
|
||||
export default function UserPicker({
|
||||
visible, members = [], value, multiple = false, title = '选择负责人',
|
||||
allowEmpty = true, onClose, onConfirm,
|
||||
}) {
|
||||
const [sel, setSel] = useState(multiple ? [] : null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
setSel(multiple ? (value || []) : (value ?? null))
|
||||
}, [visible, value, multiple])
|
||||
|
||||
const toggle = (uid) => {
|
||||
if (multiple) {
|
||||
setSel((prev) => {
|
||||
const arr = Array.isArray(prev) ? prev : []
|
||||
return arr.includes(uid) ? arr.filter((x) => x !== uid) : [...arr, uid]
|
||||
})
|
||||
} else {
|
||||
setSel((prev) => (prev === uid ? null : uid))
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
if (!multiple && sel === null && !allowEmpty) {
|
||||
Toast.show({ content: '请选择负责人' })
|
||||
return
|
||||
}
|
||||
onConfirm?.(sel)
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup visible={visible} onMaskClick={onClose} bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}>
|
||||
<div className="sheet-title">{title}</div>
|
||||
<div className="sheet-sub">仅可选择当前项目的成员</div>
|
||||
|
||||
{members.length === 0 && <div className="extras-empty">项目暂无成员</div>}
|
||||
|
||||
{members.map((m) => {
|
||||
const on = multiple ? (Array.isArray(sel) && sel.includes(m.userId)) : sel === m.userId
|
||||
return (
|
||||
<div className="pick-row" key={m.userId} onClick={() => toggle(m.userId)}>
|
||||
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
|
||||
<span className="pn">{m.name}</span>
|
||||
<span className={`ck${on ? ' on' : ''}`} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{allowEmpty && !multiple && (
|
||||
<div className="pick-row" onClick={() => setSel(null)}>
|
||||
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
|
||||
<span className="pn">清除负责人</span>
|
||||
<span className={`ck${sel === null ? ' on' : ''}`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="btn-primary" onClick={confirm}>确定</div>
|
||||
<div className="btn-ghost" onClick={onClose}>取消</div>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
// 长按拖拽 (T-UI-07, T-ST-12)
|
||||
// 用法: const drag = useLongPressDrag({ onDrop })
|
||||
// - 长按 300ms 后进入拖拽态, 产生浮层数据
|
||||
// - 松手时用 elementFromPoint 找落点: [data-column-id] / [data-task-id]
|
||||
// - 返回 { dragging, ghost, bind(task) }
|
||||
const LONG_PRESS = 300
|
||||
const MOVE_TOLERANCE = 8
|
||||
|
||||
export default function useLongPressDrag({ onDropColumn, onDropTask, onReject } = {}) {
|
||||
const [dragging, setDragging] = useState(null) // { task, x, y }
|
||||
const timer = useRef(null)
|
||||
const start = useRef(null)
|
||||
const active = useRef(false)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (timer.current) { clearTimeout(timer.current); timer.current = null }
|
||||
start.current = null
|
||||
active.current = false
|
||||
document.removeEventListener('touchmove', onMove, { passive: false })
|
||||
document.removeEventListener('touchend', onEnd)
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onEnd)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const pointOf = (ev) => {
|
||||
const t = ev.touches?.[0] || ev.changedTouches?.[0] || ev
|
||||
return { x: t.clientX, y: t.clientY }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
function onMove(ev) {
|
||||
if (!active.current) {
|
||||
// 未进入拖拽前, 若移动超过容差则取消长按(用户想滚动)
|
||||
if (start.current) {
|
||||
const p = pointOf(ev)
|
||||
if (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 p = pointOf(ev)
|
||||
setDragging((d) => (d ? { ...d, x: p.x, y: p.y } : d))
|
||||
}
|
||||
|
||||
function onEnd(ev) {
|
||||
if (!active.current) { cleanup(); return }
|
||||
const p = pointOf(ev)
|
||||
const el = document.elementFromPoint(p.x, p.y)
|
||||
const taskEl = el?.closest?.('[data-task-id]')
|
||||
const colEl = el?.closest?.('[data-column-id]')
|
||||
const task = dragging?.task
|
||||
cleanup()
|
||||
setDragging(null)
|
||||
if (!task) return
|
||||
|
||||
if (taskEl) {
|
||||
const targetId = Number(taskEl.dataset.taskId)
|
||||
if (targetId && targetId !== task.id) onDropTask?.(task, targetId)
|
||||
return
|
||||
}
|
||||
if (colEl) {
|
||||
const columnId = Number(colEl.dataset.columnId)
|
||||
if (columnId && columnId !== task.columnId) onDropColumn?.(task, columnId)
|
||||
return
|
||||
}
|
||||
onReject?.('未放到有效位置')
|
||||
}
|
||||
|
||||
const bind = useCallback((task) => {
|
||||
const onStart = (ev) => {
|
||||
if (ev.button && ev.button !== 0) return
|
||||
const p = pointOf(ev)
|
||||
start.current = p
|
||||
setDragging(null)
|
||||
active.current = false
|
||||
timer.current = setTimeout(() => {
|
||||
active.current = true
|
||||
setDragging({ task, x: p.x, y: p.y })
|
||||
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)
|
||||
}
|
||||
return {
|
||||
onTouchStart: onStart,
|
||||
onMouseDown: onStart,
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return { dragging, bind, cancel: () => { cleanup(); setDragging(null) } }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react'
|
||||
import { Toast } from 'antd-mobile'
|
||||
import TaskCard from '../../components/task/TaskCard.jsx'
|
||||
import useLongPressDrag from '../../components/task/useLongPressDrag.js'
|
||||
|
||||
// 看板视图 (T-BD-01, T-UI-07, T-ST-06/12)
|
||||
// 列横向滚动; 长按卡片拖拽换列 / 降为子任务
|
||||
export default function BoardView({
|
||||
tasks, columns, onMoveColumn, onMakeSubtask, onOpen, onToggleSub, onToggle, onQuickAdd,
|
||||
}) {
|
||||
const [overCol, setOverCol] = useState(null)
|
||||
|
||||
const drag = useLongPressDrag({
|
||||
onDropColumn: (task, columnId) => { setOverCol(null); onMoveColumn?.(task, columnId) },
|
||||
onDropTask: (task, parentId) => { setOverCol(null); onMakeSubtask?.(task, parentId) },
|
||||
onReject: (msg) => { setOverCol(null); Toast.show({ content: msg }) },
|
||||
})
|
||||
|
||||
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="board-wrap">
|
||||
{ordered.map((col) => {
|
||||
const list = (tasks || []).filter((t) => t.columnId === col.id)
|
||||
return (
|
||||
<div
|
||||
className={`bcol${overCol === col.id ? ' dragover' : ''}`}
|
||||
key={col.id}
|
||||
data-column-id={col.id}
|
||||
onDragEnter={() => setOverCol(col.id)}
|
||||
onDragLeave={() => setOverCol((c) => (c === col.id ? null : c))}
|
||||
>
|
||||
<div className="bcol-head">
|
||||
<span className="bcol-name">{col.name}</span>
|
||||
<span className="bcol-cnt">{list.length}</span>
|
||||
{col.boundStatus ? (
|
||||
<span className="bcol-bind">
|
||||
↔ {col.boundStatus === 'done' ? '完成' : col.boundStatus === 'doing' ? '执行中' : '未开始'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="bcol-bind off">未绑定</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bcol-body">
|
||||
{list.map((t) => (
|
||||
<TaskCard
|
||||
key={t.id}
|
||||
task={t}
|
||||
dragging={drag.dragging?.task?.id === t.id}
|
||||
highlight={drag.dragging && drag.dragging.task.id !== t.id && overCol === col.id}
|
||||
bindDrag={drag.bind}
|
||||
onClick={() => { if (!drag.dragging) onOpen?.(t) }}
|
||||
onToggleSub={onToggleSub}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
{list.length === 0 && <div className="bcol-empty">暂无任务</div>}
|
||||
<div className="badd" onClick={() => onQuickAdd?.(col.id)}>+ 添加任务</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{drag.dragging && (
|
||||
<div
|
||||
className="drag-ghost"
|
||||
style={{ left: drag.dragging.x, top: drag.dragging.y }}
|
||||
>
|
||||
{drag.dragging.task.title}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, Toast } from 'antd-mobile'
|
||||
import { columnApi } from '../../api/task.js'
|
||||
import { RETENTION_OPTIONS, RETENTION_LABEL, STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||
|
||||
const MAX_COLUMNS = 10
|
||||
|
||||
// 列设置 (T-COL-*, T-BD-02/09/10, T-FL-01/02/15)
|
||||
// 1) 列管理: 改名 / 新增 / 删除 / 排序
|
||||
// 2) 状态绑定: 每列独立选择 不绑定 / 未开始 / 执行中 / 完成
|
||||
// 3) 保留选项: 每列独立 6 档
|
||||
export default function ColumnSettings({ projectId, columns, onClose, onSaved }) {
|
||||
const ordered = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const [list, setList] = useState(ordered)
|
||||
const [editingName, setEditingName] = useState(null) // { id, name }
|
||||
const [pending, setPending] = useState(false)
|
||||
|
||||
const patchLocal = (id, patch) =>
|
||||
setList((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c)))
|
||||
|
||||
async function save() {
|
||||
setPending(true)
|
||||
try {
|
||||
// 逐列保存变更(只提交与初始不同的字段)
|
||||
for (const c of list) {
|
||||
const orig = ordered.find((o) => o.id === c.id)
|
||||
if (!orig) continue
|
||||
const body = {}
|
||||
if (orig.name !== c.name) body.name = c.name
|
||||
if (orig.boundStatus !== c.boundStatus) body.boundStatus = c.boundStatus
|
||||
if (orig.retention !== c.retention) body.retention = c.retention
|
||||
if (Object.keys(body).length) await columnApi.update(c.id, body)
|
||||
}
|
||||
Toast.show({ content: '已保存' })
|
||||
onSaved?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addColumn() {
|
||||
if (list.length >= MAX_COLUMNS) { Toast.show({ content: `最多 ${MAX_COLUMNS} 列` }); return }
|
||||
const name = `新列 ${list.length + 1}`
|
||||
try {
|
||||
const r = await columnApi.add(projectId, { name })
|
||||
setList((prev) => [...prev, r.item])
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeColumn(col) {
|
||||
if (list.length <= 1) { Toast.show({ content: '至少保留一列' }); return }
|
||||
const idx = list.findIndex((c) => c.id === col.id)
|
||||
const fallback = list[idx + 1] || list[idx - 1]
|
||||
const ok = await Dialog.confirm({
|
||||
content: `删除「${col.name}」?其中的任务将迁移到「${fallback.name}」,其状态会按目标列的绑定重新确定。`,
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await columnApi.remove(col.id, fallback.id)
|
||||
setList((prev) => prev.filter((c) => c.id !== col.id))
|
||||
Toast.show({ content: '已删除' })
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
function move(idx, dir) {
|
||||
const next = [...list]
|
||||
const j = idx + dir
|
||||
if (j < 0 || j >= next.length) return
|
||||
;[next[idx], next[j]] = [next[j], next[idx]]
|
||||
setList(next)
|
||||
// 立即持久化顺序
|
||||
columnApi.order(projectId, next.map((c) => c.id)).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">列设置</span>
|
||||
<div className="right">
|
||||
<span className="mini-btn solid" onClick={save}>{pending ? '保存中…' : '保存'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cs-body">
|
||||
<div className="set-hint">
|
||||
调整列顺序会影响「改状态时自动归位」的目标列(取第一个绑定该状态的列)。
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '12px 14px 8px' }}>列与状态绑定</div>
|
||||
|
||||
{list.map((col, idx) => (
|
||||
<div className="set-row" key={col.id}>
|
||||
<span className="cs-ord">
|
||||
<span onClick={() => move(idx, -1)}>↑</span>
|
||||
<span onClick={() => move(idx, 1)}>↓</span>
|
||||
</span>
|
||||
<span
|
||||
className="sname"
|
||||
onClick={() => setEditingName({ id: col.id, name: col.name })}
|
||||
>
|
||||
{col.name}
|
||||
</span>
|
||||
<div className="sctrl">
|
||||
<select
|
||||
className={`drop${col.boundStatus ? '' : ' off'}`}
|
||||
value={col.boundStatus || ''}
|
||||
onChange={(e) => patchLocal(col.id, { boundStatus: e.target.value || null })}
|
||||
>
|
||||
<option value="">不绑定状态</option>
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="cs-del" onClick={() => removeColumn(col)}>删</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ padding: '12px 14px' }}>
|
||||
<span className="mini-btn" onClick={addColumn}>+ 新增列</span>
|
||||
<span className="cs-count">当前 {list.length} / {MAX_COLUMNS} 列</span>
|
||||
</div>
|
||||
|
||||
<div className="section-h" style={{ margin: '6px 14px 8px' }}>已完成任务保留</div>
|
||||
|
||||
{list.map((col) => (
|
||||
<div className="set-row" key={col.id}>
|
||||
<span className="sname" style={{ width: 82 }}>{col.name}</span>
|
||||
<div className="sctrl">
|
||||
<select
|
||||
className="drop"
|
||||
value={col.retention || 'forever'}
|
||||
onChange={(e) => patchLocal(col.id, { retention: e.target.value })}
|
||||
>
|
||||
{RETENTION_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>{RETENTION_LABEL[o]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="set-hint">
|
||||
保留选项:永不显示 / 当天 / 3 天 / 本周 / 本月 / 永久显示。<br />
|
||||
仅影响已完成任务;未完成任务始终展示。列表与看板按同一规则过滤。
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 14px 20px' }}>
|
||||
<div className="btn-primary" onClick={save}>{pending ? '保存中…' : '保存'}</div>
|
||||
<div className="btn-ghost" onClick={onClose}>取消</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
visible={!!editingName}
|
||||
title="列名"
|
||||
content={
|
||||
<input
|
||||
className="inp"
|
||||
value={editingName?.name || ''}
|
||||
maxLength={20}
|
||||
onChange={(e) => setEditingName((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
}
|
||||
actions={[
|
||||
{ key: 'c', text: '取消', onClick: () => setEditingName(null) },
|
||||
{
|
||||
key: 'ok',
|
||||
text: '确定',
|
||||
bold: true,
|
||||
onClick: () => {
|
||||
const name = (editingName?.name || '').trim()
|
||||
if (!name) { Toast.show({ content: '列名不能为空' }); return }
|
||||
if (list.some((c) => c.id !== editingName.id && c.name === name)) {
|
||||
Toast.show({ content: '列名已存在' }); return
|
||||
}
|
||||
patchLocal(editingName.id, { name })
|
||||
setEditingName(null)
|
||||
},
|
||||
},
|
||||
]}
|
||||
onClose={() => setEditingName(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react'
|
||||
import TaskRow from '../../components/task/TaskRow.jsx'
|
||||
|
||||
// 列表视图 (T-VW-01, T-ST-04/05)
|
||||
// 按看板列分组展示(列表不分组也允许, 这里默认分组更接近 Todoist)
|
||||
// 子任务缩进 + 折叠展开
|
||||
export default function ListView({
|
||||
tasks, columns, onToggle, onOpen, onAddSub,
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState({})
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return (
|
||||
<div className="empty" style={{ paddingTop: 90 }}>
|
||||
<div className="ico">✓</div>
|
||||
<p>这个项目还没有任务</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 分组: 按列顺序, 未匹配列的任务归到一起
|
||||
const groups = []
|
||||
const orderedCols = [...(columns || [])].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
for (const c of orderedCols) {
|
||||
const list = tasks.filter((t) => t.columnId === c.id)
|
||||
if (list.length) groups.push({ key: c.id, name: c.name, list })
|
||||
}
|
||||
const known = new Set(orderedCols.map((c) => c.id))
|
||||
const orphans = tasks.filter((t) => !known.has(t.columnId))
|
||||
if (orphans.length) groups.push({ key: 'other', name: '其他', list: orphans })
|
||||
|
||||
return (
|
||||
<div className="list-body">
|
||||
{groups.map((g) => (
|
||||
<div key={g.key}>
|
||||
<div className="group-title">
|
||||
{g.name}
|
||||
<span className="cnt">{g.list.length}</span>
|
||||
</div>
|
||||
|
||||
{g.list.map((t) => {
|
||||
const kids = t.children || []
|
||||
const isCollapsed = !!collapsed[t.id]
|
||||
return (
|
||||
<div key={t.id}>
|
||||
<TaskRow
|
||||
task={t}
|
||||
onToggle={onToggle}
|
||||
onClick={() => onOpen(t)}
|
||||
onMore={() => onOpen(t)}
|
||||
/>
|
||||
{kids.length > 0 && (
|
||||
<>
|
||||
{!isCollapsed && kids.map((c) => (
|
||||
<TaskRow key={c.id} task={c} indent onToggle={onToggle} onClick={() => onOpen(c)} />
|
||||
))}
|
||||
<div
|
||||
className="collapse"
|
||||
onClick={() => setCollapsed((p) => ({ ...p, [t.id]: !p[t.id] }))}
|
||||
>
|
||||
{isCollapsed ? `展开 ${kids.length} 个子任务 ⌄` : `收起 ${kids.length} 个子任务 ⌃`}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{kids.length === 0 && onAddSub && (
|
||||
<div className="sub-add-hint" onClick={() => onAddSub(t)}>+ 子任务</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, Toast } from 'antd-mobile'
|
||||
import { projectApi } from '../../api/task.js'
|
||||
import { useAuth } from '../../auth/AuthContext.jsx'
|
||||
|
||||
// 项目成员 (T-PJ-06/07, T-AS-06, T-PM-02)
|
||||
export default function ProjectMembers({ project, onClose, onChanged }) {
|
||||
const { user } = useAuth()
|
||||
const meId = user?.id
|
||||
const [members, setMembers] = useState(project.members || [])
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
async function add() {
|
||||
const e = email.trim()
|
||||
if (!e) return
|
||||
try {
|
||||
await projectApi.addMember(project.id, { email: e })
|
||||
setEmail('')
|
||||
setAdding(false)
|
||||
Toast.show({ content: '已添加' })
|
||||
onChanged?.()
|
||||
onClose?.()
|
||||
} catch (err) {
|
||||
Toast.show({ content: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(m, isSelf) {
|
||||
const ok = await Dialog.confirm({
|
||||
content: isSelf ? '退出该项目?退出后将不再可见。' : `移除成员「${m.name}」?`,
|
||||
confirmText: isSelf ? '退出' : '移除',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await projectApi.removeMember(project.id, m.userId)
|
||||
setMembers((prev) => prev.filter((x) => x.userId !== m.userId))
|
||||
Toast.show({ content: isSelf ? '已退出' : '已移除' })
|
||||
onChanged?.()
|
||||
if (isSelf) onClose?.()
|
||||
} catch (err) {
|
||||
Toast.show({ content: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cs-page">
|
||||
<div className="navbar-sub">
|
||||
<span className="back" onClick={onClose}>‹</span>
|
||||
<span className="title">{project.name}</span>
|
||||
<div className="right">
|
||||
{!project.isPersonal && (
|
||||
<span className="mini-btn solid" onClick={() => setAdding(true)}>+ 添加</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cs-body">
|
||||
<div className="set-hint">
|
||||
{project.isPersonal
|
||||
? '个人默认项目不可添加成员。'
|
||||
: '项目内所有成员权限均等、全读写;负责人与成员只是信息标记,不影响权限。'}
|
||||
</div>
|
||||
|
||||
{members.map((m) => {
|
||||
const isOwner = m.userId === project.ownerId
|
||||
const isMe = m.userId === meId
|
||||
return (
|
||||
<div className="set-row" key={m.userId} style={{ padding: '12px 14px' }}>
|
||||
<div className="avatar">{(m.name || '?').slice(0, 1)}</div>
|
||||
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
|
||||
{m.name}
|
||||
{isMe && <span className="tag grey" style={{ marginLeft: 6 }}>我</span>}
|
||||
{isOwner && <span className="tag grey" style={{ marginLeft: 6 }}>创建人</span>}
|
||||
</span>
|
||||
{isMe && !project.isPersonal && (
|
||||
<span className="link danger" onClick={() => remove(m, true)}>退出</span>
|
||||
)}
|
||||
{!isMe && isOwner && <span className="link" style={{ color: '#999' }}>—</span>}
|
||||
{!isMe && !isOwner && !project.isPersonal && (
|
||||
<span className="link danger" onClick={() => remove(m, false)}>移除</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="set-hint">
|
||||
成员被移出项目后,其担任的负责人 / 成员标记会保留历史并显示为「已退出」,不清空、不报错。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
visible={adding}
|
||||
title="添加成员"
|
||||
content={
|
||||
<input
|
||||
className="inp"
|
||||
placeholder="输入成员邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
}
|
||||
actions={[
|
||||
{ key: 'c', text: '取消', onClick: () => setAdding(false) },
|
||||
{ key: 'ok', text: '添加', bold: true, onClick: add },
|
||||
]}
|
||||
onClose={() => setAdding(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { 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 ListView from './ListView.jsx'
|
||||
import BoardView from './BoardView.jsx'
|
||||
import ColumnSettings from './ColumnSettings.jsx'
|
||||
import TaskDetail from './TaskDetail.jsx'
|
||||
import ProjectMembers from './ProjectMembers.jsx'
|
||||
|
||||
// 项目视图容器: 列表 / 看板 切换 + 数据装载 + 乐观更新
|
||||
export default function ProjectView({ projectId, onBack }) {
|
||||
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 [adding, setAdding] = useState(false)
|
||||
const [tick, setTick] = useState(0) // 保留窗口重算触发
|
||||
|
||||
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 { /* 已回滚并提示 */ }
|
||||
}
|
||||
|
||||
/** 改状态 (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 */ }
|
||||
}
|
||||
|
||||
/** 拖拽降为子任务 (T-ST-12) */
|
||||
const makeSubtask = async (task, parentId) => {
|
||||
try {
|
||||
await updateTask(task.id, { parentId })
|
||||
// 成为子任务后不再作为顶层卡片出现
|
||||
setTasks((prev) => (prev || []).filter((t) => t.id !== task.id))
|
||||
Toast.show({ content: '已转为子任务' })
|
||||
load()
|
||||
} catch { /* 错误已提示 */ }
|
||||
}
|
||||
|
||||
/** 新建任务 */
|
||||
async function createTask(title, parentId = null) {
|
||||
const t = String(title || '').trim()
|
||||
if (!t) return
|
||||
try {
|
||||
await taskApi.create(projectId, { title: t, parentId })
|
||||
setAdding(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
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="ico err">!</div>
|
||||
<p>{err}</p>
|
||||
<div className="mini-btn solid" onClick={load}>重试</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!project || !tasks) {
|
||||
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="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>
|
||||
<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>
|
||||
</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 className="right">
|
||||
<span className="mini-btn grey" onClick={() => setShowColumns(true)}>列设置</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<div className="quick-add">
|
||||
<input
|
||||
className="inp"
|
||||
autoFocus
|
||||
placeholder="输入任务标题,回车创建"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
||||
if (e.key === 'Escape') setAdding(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'list' ? (
|
||||
<ListView
|
||||
tasks={visibleTasks}
|
||||
columns={columns}
|
||||
onToggle={toggleDone}
|
||||
onOpen={(t) => setOpenTask(t)}
|
||||
onAddSub={(parent) => setOpenTask(parent)}
|
||||
/>
|
||||
) : (
|
||||
<BoardView
|
||||
tasks={visibleTasks}
|
||||
columns={columns}
|
||||
onMoveColumn={moveToColumn}
|
||||
onMakeSubtask={makeSubtask}
|
||||
onOpen={(t) => setOpenTask(t)}
|
||||
onToggleSub={(c) => toggleDone(c)}
|
||||
onToggle={(t) => toggleDone(t)}
|
||||
onQuickAdd={() => { setAdding(true) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{openTask && (
|
||||
<TaskDetail
|
||||
task={openTask}
|
||||
columns={columns}
|
||||
members={project.members || []}
|
||||
onClose={() => setOpenTask(null)}
|
||||
onSaved={() => { setOpenTask(null); load() }}
|
||||
onChanged={load}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showColumns && (
|
||||
<ColumnSettings
|
||||
projectId={projectId}
|
||||
columns={columns}
|
||||
onClose={() => setShowColumns(false)}
|
||||
onSaved={() => { setShowColumns(false); load() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showMembers && (
|
||||
<ProjectMembers
|
||||
project={project}
|
||||
onClose={() => setShowMembers(false)}
|
||||
onChanged={load}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dialog, Popup, Toast } from 'antd-mobile'
|
||||
import { projectApi, taskApi } from '../../api/task.js'
|
||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||
import ExtrasCard from '../../components/task/ExtrasCard.jsx'
|
||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||
|
||||
// 任务详情 (T-TK-01~13, T-ST-03, T-AS-05)
|
||||
export default function TaskDetail({ task, columns, members, onClose, onSaved, onChanged }) {
|
||||
const [form, setForm] = useState(() => ({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
extras: task.extras || [],
|
||||
memberIds: (task.members || []).map((m) => m.userId),
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
tags: task.tags || [],
|
||||
}))
|
||||
const [subs, setSubs] = useState(task.children || [])
|
||||
const [newSub, setNewSub] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [picker, setPicker] = useState(null) // 'assignee' | 'members'
|
||||
const [copies, setCopies] = useState(null) // 其他项目列表(拷贝用)
|
||||
|
||||
const assigneeName = members.find((m) => m.userId === form.assigneeId)?.name
|
||||
const memberNames = members.filter((m) => form.memberIds.includes(m.userId)).map((m) => m.name)
|
||||
|
||||
useEffect(() => {
|
||||
setForm({
|
||||
title: task.title || '',
|
||||
description: task.description || '',
|
||||
extras: task.extras || [],
|
||||
memberIds: (task.members || []).map((m) => m.userId),
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
tags: task.tags || [],
|
||||
})
|
||||
setSubs(task.children || [])
|
||||
}, [task])
|
||||
|
||||
async function save() {
|
||||
if (!form.title.trim()) { Toast.show({ content: '标题不能为空' }); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
const r = await taskApi.update(task.id, {
|
||||
title: form.title.trim(),
|
||||
description: form.description,
|
||||
extras: form.extras,
|
||||
assigneeId: form.assigneeId,
|
||||
memberIds: form.memberIds,
|
||||
status: form.status,
|
||||
tags: form.tags,
|
||||
})
|
||||
Toast.show({ content: '已保存' })
|
||||
onSaved?.(r.item)
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function addSub() {
|
||||
const t = newSub.trim()
|
||||
if (!t) return
|
||||
try {
|
||||
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
||||
setSubs((prev) => [...prev, r.item])
|
||||
setNewSub('')
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSub(sub) {
|
||||
const next = sub.status === 'done' ? 'todo' : 'done'
|
||||
try {
|
||||
const r = await taskApi.update(sub.id, { status: next })
|
||||
setSubs((prev) => prev.map((s) => (s.id === sub.id ? r.item : s)))
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTask() {
|
||||
const ok = await Dialog.confirm({
|
||||
content: subs.length
|
||||
? `删除该任务?将一并删除 ${subs.length} 条子任务。`
|
||||
: '删除该任务?',
|
||||
confirmText: '删除',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await taskApi.remove(task.id)
|
||||
Toast.show({ content: '已删除' })
|
||||
onSaved?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function openCopy() {
|
||||
try {
|
||||
const d = await projectApi.list()
|
||||
setCopies((d.items || []).filter((p) => p.id !== task.projectId && !p.archived))
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
async function doCopy(projectId) {
|
||||
try {
|
||||
const r = await taskApi.copy(task.id, projectId)
|
||||
const dropped = r.dropped || {}
|
||||
const lost = [dropped.assignee?.name, ...(dropped.members || []).map((m) => m.name)].filter(Boolean)
|
||||
Toast.show({
|
||||
content: lost.length
|
||||
? `已拷贝到目标项目;${lost.join('、')} 不是该项目成员,已不保留`
|
||||
: '已拷贝到目标项目',
|
||||
})
|
||||
setCopies(null)
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible
|
||||
onMaskClick={onClose}
|
||||
position="bottom"
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, maxHeight: '88vh', overflowY: 'auto', padding: '16px 16px 14px' }}
|
||||
>
|
||||
<div className="sheet-title">{task.title}</div>
|
||||
<div className="sheet-sub">
|
||||
{task.column?.name} · {STATUS_LABEL[task.status]}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>标题</label>
|
||||
<input
|
||||
className="inp"
|
||||
value={form.title}
|
||||
maxLength={200}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>描述</label>
|
||||
<textarea
|
||||
className="inp"
|
||||
rows={2}
|
||||
placeholder="补充说明…"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>状态</label>
|
||||
<div className="seg">
|
||||
{STATUS_OPTIONS.map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={form.status === s ? 'on' : ''}
|
||||
onClick={() => setForm((p) => ({ ...p, status: s }))}
|
||||
>
|
||||
{STATUS_LABEL[s]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>负责人</label>
|
||||
<div className="pick-row" onClick={() => setPicker('assignee')}>
|
||||
{assigneeName ? (
|
||||
<div className="avatar">{assigneeName.slice(0, 1)}</div>
|
||||
) : (
|
||||
<div className="avatar" style={{ background: '#f2f3f5', color: '#b6bcc4' }}>—</div>
|
||||
)}
|
||||
<span className="pn">{assigneeName || '未指派'}</span>
|
||||
<span className="link">更换</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label>任务成员</label>
|
||||
<div className="pick-row" onClick={() => setPicker('members')}>
|
||||
<div className="avatar" style={{ background: '#eef2f7', color: '#7b8794' }}>
|
||||
{form.memberIds.length || 0}
|
||||
</div>
|
||||
<span className="pn" style={{ fontSize: 12.5, color: form.memberIds.length ? 'inherit' : '#999' }}>
|
||||
{memberNames.length ? memberNames.join('、') : '未选择成员'}
|
||||
</span>
|
||||
<span className="link">编辑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ExtrasCard
|
||||
extras={form.extras}
|
||||
onChange={(next) => setForm((p) => ({ ...p, extras: next }))}
|
||||
/>
|
||||
|
||||
<div className="section-h">子任务 <span className="tag">{subs.length}</span></div>
|
||||
{subs.map((s) => (
|
||||
<div className="pick-row" key={s.id} onClick={() => toggleSub(s)}>
|
||||
<span className={`ck${s.status === 'done' ? ' on' : ''}`} />
|
||||
<span className="pn">{s.title}</span>
|
||||
<StatusDot status={s.status} />
|
||||
</div>
|
||||
))}
|
||||
<div className="extra-row" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
className="inp"
|
||||
placeholder="添加子任务,回车确认"
|
||||
value={newSub}
|
||||
maxLength={200}
|
||||
onChange={(e) => setNewSub(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') addSub() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="btn-primary" onClick={save}>{saving ? '保存中…' : '保存'}</div>
|
||||
<div className="btn-ghost" onClick={openCopy}>拷贝到其他项目</div>
|
||||
<div className="btn-danger" onClick={removeTask}>
|
||||
删除任务{subs.length > 0 && <span className="del-hint">(将一并删除 {subs.length} 条子任务)</span>}
|
||||
</div>
|
||||
|
||||
<UserPicker
|
||||
visible={picker === 'assignee'}
|
||||
title="选择负责人"
|
||||
members={members}
|
||||
value={form.assigneeId}
|
||||
onClose={() => setPicker(null)}
|
||||
onConfirm={(uid) => setForm((p) => ({ ...p, assigneeId: uid }))}
|
||||
/>
|
||||
<UserPicker
|
||||
visible={picker === 'members'}
|
||||
title="选择任务成员"
|
||||
multiple
|
||||
members={members}
|
||||
value={form.memberIds}
|
||||
onClose={() => setPicker(null)}
|
||||
onConfirm={(ids) => setForm((p) => ({ ...p, memberIds: Array.isArray(ids) ? ids : [] }))}
|
||||
/>
|
||||
|
||||
<Popup
|
||||
visible={!!copies}
|
||||
onMaskClick={() => setCopies(null)}
|
||||
bodyStyle={{ borderTopLeftRadius: 12, borderTopRightRadius: 12, padding: '16px 16px 14px' }}
|
||||
>
|
||||
<div className="sheet-title">跨项目拷贝</div>
|
||||
<div className="sheet-sub">源任务保持不变;新任务的创建人是你</div>
|
||||
{(copies || []).length === 0 && <div className="extras-empty">没有其他可拷贝的项目</div>}
|
||||
{(copies || []).map((p) => (
|
||||
<div className="pick-row" key={p.id} onClick={() => doCopy(p.id)}>
|
||||
<div className="avatar">{(p.name || '?').slice(0, 1)}</div>
|
||||
<span className="pn">{p.name}</span>
|
||||
<span className="link">拷贝</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="btn-ghost" onClick={() => setCopies(null)}>取消</div>
|
||||
</Popup>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Dialog, DotLoading, Empty, 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 ProjectView from './ProjectView.jsx'
|
||||
|
||||
// 任务模块容器 (T-PM-06)
|
||||
// 无 :pid -> 项目列表
|
||||
// 有 :pid -> 该项目视图(列表/看板)
|
||||
export default function TaskPage() {
|
||||
const { pid } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [projects, setProjects] = useState(null)
|
||||
const [err, setErr] = useState('')
|
||||
|
||||
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])
|
||||
|
||||
// 记住上次访问的项目 (T-VW-07)
|
||||
useEffect(() => {
|
||||
if (pid) setLastProjectId(Number(pid))
|
||||
}, [pid])
|
||||
|
||||
if (pid) {
|
||||
return <ProjectView projectId={Number(pid)} onBack={() => navigate('/task')} />
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProjectList({ projects, err, onRetry, onOpen, onChanged }) {
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
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 active = projects.filter((p) => !p.archived)
|
||||
const archived = projects.filter((p) => p.archived)
|
||||
|
||||
async function create() {
|
||||
const t = name.trim()
|
||||
if (!t) { Toast.show({ content: '请输入项目名称' }); return }
|
||||
setSaving(true)
|
||||
try {
|
||||
await projectApi.create({ name: t })
|
||||
setName('')
|
||||
setCreating(false)
|
||||
Toast.show({ content: '已创建' })
|
||||
onChanged?.()
|
||||
} catch (e) {
|
||||
Toast.show({ content: e.message })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const COLORS = ['#1677ff', '#00b578', '#ff8f1f', '#a855f7', '#14b8a6', '#f43f9e']
|
||||
const colorOf = (p) => COLORS[Math.abs(p.id) % COLORS.length]
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="proj-head">
|
||||
<b>我的项目</b>
|
||||
<div className="right">
|
||||
<div className="mini-btn" onClick={() => setCreating(true)}>+ 新建</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{active.map((p) => (
|
||||
<div className="proj-item" key={p.id} onClick={() => 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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 避免未使用告警
|
||||
void Empty
|
||||
@@ -0,0 +1,97 @@
|
||||
// 任务模块前端: 自然日边界 + 保留选项可见性
|
||||
// 与后端 server/task-domain/date.js 保持同一口径, 改动需同步
|
||||
export const TZ = 'Asia/Shanghai'
|
||||
|
||||
export const RETENTION_LABEL = {
|
||||
never: '永不显示',
|
||||
today: '当天',
|
||||
'3d': '3 天',
|
||||
week: '本周',
|
||||
month: '本月',
|
||||
forever: '永久显示',
|
||||
}
|
||||
|
||||
export const RETENTION_OPTIONS = ['never', 'today', '3d', 'week', 'month', 'forever']
|
||||
|
||||
export const STATUS_LABEL = { todo: '未开始', doing: '执行中', done: '完成' }
|
||||
export const STATUS_OPTIONS = ['todo', 'doing', 'done']
|
||||
|
||||
const FMT = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
})
|
||||
|
||||
export function toDateStr(d = new Date()) {
|
||||
return FMT.format(d instanceof Date ? d : new Date(d))
|
||||
}
|
||||
|
||||
function toUTC(s) {
|
||||
const [y, m, d] = String(s).split('-').map(Number)
|
||||
return new Date(Date.UTC(y, (m || 1) - 1, d || 1))
|
||||
}
|
||||
|
||||
function fromUTC(dt) {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function addDays(s, n) {
|
||||
const d = toUTC(s)
|
||||
d.setUTCDate(d.getUTCDate() + n)
|
||||
return fromUTC(d)
|
||||
}
|
||||
|
||||
/** 周一为一周第一天 */
|
||||
export function startOfWeek(s) {
|
||||
const dow = (toUTC(s).getUTCDay() + 6) % 7
|
||||
return addDays(s, -dow)
|
||||
}
|
||||
|
||||
export function startOfMonth(s) {
|
||||
return String(s).slice(0, 8) + '01'
|
||||
}
|
||||
|
||||
/** 保留选项 -> 窗口起点 ("YYYY-MM-DD") 或 特殊标记 */
|
||||
export function retentionStart(option, now = new Date()) {
|
||||
const today = toDateStr(now)
|
||||
switch (option) {
|
||||
case 'never': return { mode: 'never' }
|
||||
case 'forever': return { mode: 'all' }
|
||||
case 'today': return { mode: 'since', date: today }
|
||||
case '3d': return { mode: 'since', date: addDays(today, -2) }
|
||||
case 'week': return { mode: 'since', date: startOfWeek(today) }
|
||||
case 'month': return { mode: 'since', date: startOfMonth(today) }
|
||||
default: return { mode: 'all' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 单条任务在某保留选项下是否可见 */
|
||||
export function isVisibleByRetention(task, option, now = new Date()) {
|
||||
if (task.status !== 'done') return true
|
||||
const win = retentionStart(option, now)
|
||||
if (win.mode === 'never') return false
|
||||
if (win.mode === 'all') return true
|
||||
if (!task.doneAt) return false
|
||||
return toDateStr(task.doneAt) >= win.date
|
||||
}
|
||||
|
||||
/** 按任务所属列的保留选项过滤任务 */
|
||||
export function filterVisible(tasks, columns, now = new Date()) {
|
||||
const map = new Map((columns || []).map((c) => [c.id, c.retention || 'forever']))
|
||||
return (tasks || []).filter((t) => isVisibleByRetention(t, map.get(t.columnId) || 'forever', now))
|
||||
}
|
||||
|
||||
/** 距离下一个自然日/周/月边界的毫秒数, 用于定时重算 (T-EX-18) */
|
||||
export function msToNextBoundary(option, now = new Date()) {
|
||||
if (!['today', '3d', 'week', 'month'].includes(option)) return null
|
||||
const today = toDateStr(now)
|
||||
let next
|
||||
if (option === 'today' || option === '3d') next = addDays(today, 1)
|
||||
else if (option === 'week') next = addDays(startOfWeek(today), 7)
|
||||
else {
|
||||
const [y, m] = today.split('-').map(Number)
|
||||
const nm = new Date(Date.UTC(y, m, 1))
|
||||
next = nm.toISOString().slice(0, 10)
|
||||
}
|
||||
// 本地零点转北京时间零点: 用 Date 构造 "next 00:00 +08:00"
|
||||
const target = new Date(next + 'T00:00:00+08:00').getTime()
|
||||
return Math.max(1000, target - now.getTime())
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// 任务模块: 设备端偏好 (不落库, 不跨设备)
|
||||
// lastProjectId 上次访问的项目 (T-VW-07)
|
||||
// viewMode 按项目记忆的视图 list | board (T-VW-03/06)
|
||||
const KEY = 'iboard_task_prefs'
|
||||
|
||||
function read() {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY)
|
||||
if (!raw) return { lastProjectId: null, viewMode: {} }
|
||||
const obj = JSON.parse(raw)
|
||||
return {
|
||||
lastProjectId: obj.lastProjectId ?? null,
|
||||
viewMode: obj.viewMode && typeof obj.viewMode === 'object' ? obj.viewMode : {},
|
||||
}
|
||||
} catch {
|
||||
return { lastProjectId: null, viewMode: {} }
|
||||
}
|
||||
}
|
||||
|
||||
function write(obj) {
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(obj))
|
||||
} catch { /* 忽略配额错误 */ }
|
||||
}
|
||||
|
||||
export function getLastProjectId() {
|
||||
return read().lastProjectId
|
||||
}
|
||||
|
||||
export function setLastProjectId(id) {
|
||||
const cur = read()
|
||||
cur.lastProjectId = id ?? null
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 视图记忆: 无记录返回 null, 由调用方决定默认值 */
|
||||
export function getViewMode(projectId) {
|
||||
if (!projectId) return null
|
||||
return read().viewMode[String(projectId)] || null
|
||||
}
|
||||
|
||||
export function setViewMode(projectId, mode) {
|
||||
if (!projectId) return
|
||||
const cur = read()
|
||||
cur.viewMode[String(projectId)] = mode
|
||||
write(cur)
|
||||
}
|
||||
|
||||
/** 清理已不可见的项目记录 */
|
||||
export function prunePrefs(visibleIds) {
|
||||
const set = new Set((visibleIds || []).map(String))
|
||||
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]
|
||||
}
|
||||
write(cur)
|
||||
}
|
||||
@@ -883,3 +883,216 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.mv-sheet-row.danger { color: var(--color-danger); }
|
||||
.mv-sheet-row.disabled { opacity: .45; cursor: default; }
|
||||
.mv-sheet-row .k { font-size: 11px; color: #b8bcc4; }
|
||||
|
||||
/* ================== 任务模块 (V0.2, 需求 T-*) ================== */
|
||||
/* 项目列表 / 列表视图 / 看板视图 / 列设置 / 任务详情 / 成员 */
|
||||
.task-content { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.task-content.is-board { overflow: hidden; }
|
||||
|
||||
/* ---- 项目列表 ---- */
|
||||
.proj-head { display: flex; align-items: center; padding: 12px 14px 8px; }
|
||||
.proj-head b { font-size: 16px; }
|
||||
.proj-head .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.proj-group { font-size: 11px; color: #b8bcc4; letter-spacing: .5px; padding: 10px 16px 6px; }
|
||||
.proj-item {
|
||||
display: flex; align-items: center; gap: 12px; background: #fff; border-radius: 10px;
|
||||
padding: 13px 14px; margin: 0 12px 8px; box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer;
|
||||
}
|
||||
.proj-item .pico {
|
||||
width: 34px; height: 34px; border-radius: 9px; display: flex; align-items: center;
|
||||
justify-content: center; color: #fff; font-size: 14px; font-weight: 700; flex-shrink: 0;
|
||||
}
|
||||
.proj-item .pmain { flex: 1; min-width: 0; }
|
||||
.proj-item .ptitle { font-size: 14.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.proj-item .psub { font-size: 11.5px; color: var(--color-muted); margin-top: 2px; }
|
||||
.proj-item .pcount { font-size: 15px; color: #c0c4cc; flex-shrink: 0; }
|
||||
.badge { font-size: 10px; color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 0 5px; font-weight: 400; }
|
||||
|
||||
/* ---- 视图头部 / 快速新增 ---- */
|
||||
.pv-head { padding: 10px 14px 8px; display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||
.pv-head .seg { display: flex; background: #f2f3f5; border-radius: 8px; padding: 3px; width: 112px; }
|
||||
.pv-head .seg div { flex: 1; text-align: center; font-size: 13px; padding: 5px 0; border-radius: 6px; color: var(--color-muted); cursor: pointer; }
|
||||
.pv-head .seg div.on { background: #fff; color: var(--color-text); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||
.pv-head .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.mini-btn {
|
||||
font-size: 11px; border: 1px solid var(--color-primary); color: var(--color-primary);
|
||||
border-radius: 999px; padding: 3px 10px; background: #fff; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.mini-btn.solid { background: var(--color-primary); color: #fff; }
|
||||
.mini-btn.grey { border-color: #dcdcdc; color: var(--color-muted); }
|
||||
.quick-add { padding: 0 12px 8px; flex-shrink: 0; }
|
||||
|
||||
/* ---- 列表视图 ---- */
|
||||
.list-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 16px; }
|
||||
.group-title { display: flex; align-items: center; gap: 7px; padding: 12px 16px 6px; font-size: 12px; color: var(--color-muted); font-weight: 600; }
|
||||
.group-title::before { content: ""; width: 3px; height: 12px; border-radius: 2px; background: var(--color-primary); }
|
||||
.group-title .cnt { color: #b8bcc4; font-weight: 400; }
|
||||
.task-row {
|
||||
display: flex; align-items: flex-start; gap: 9px; background: #fff; border-radius: 9px;
|
||||
padding: 10px 12px; margin: 0 12px 7px; box-shadow: 0 1px 2px rgba(0,0,0,.04); cursor: pointer;
|
||||
}
|
||||
.task-row.done { background: #fafbfa; }
|
||||
.task-row .box, .sub-row .box {
|
||||
width: 19px; height: 19px; border-radius: 50%; border: 1.5px solid #d0d0d0;
|
||||
display: flex; align-items: center; justify-content: center; flex-shrink: 0; margin-top: 1px; cursor: pointer;
|
||||
}
|
||||
.task-row.done .box, .sub-row.done .box { background: #00b578; border-color: #00b578; }
|
||||
.box svg { opacity: 0; }
|
||||
.task-row.done .box svg, .sub-row.done .box svg { opacity: 1; }
|
||||
.task-main { flex: 1; min-width: 0; }
|
||||
.task-title { font-size: 14px; display: flex; align-items: center; gap: 6px; word-break: break-word; }
|
||||
.task-row.done .task-title { color: #a0a0a0; text-decoration: line-through; }
|
||||
.task-meta { display: flex; align-items: center; gap: 7px; margin-top: 4px; font-size: 11px; color: #999; flex-wrap: wrap; }
|
||||
.task-meta .col { color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
|
||||
.task-meta .assignee { font-size: 12px; font-weight: 600; color: var(--color-text); }
|
||||
.task-meta .assignee.none { color: #b8bcc4; font-weight: 400; }
|
||||
.task-meta .members { font-size: 10.5px; color: #a7adb6; }
|
||||
.task-right { display: flex; align-items: center; flex-shrink: 0; }
|
||||
.more { color: #c2c7cf; font-size: 16px; line-height: 1; padding: 0 2px; cursor: pointer; }
|
||||
.sub-row {
|
||||
display: flex; align-items: center; gap: 8px; background: #fff; border-radius: 8px;
|
||||
padding: 8px 12px 8px 30px; margin: -2px 12px 7px 26px; box-shadow: 0 1px 2px rgba(0,0,0,.03);
|
||||
position: relative; cursor: pointer;
|
||||
}
|
||||
.sub-row::before { content: ""; position: absolute; left: 14px; top: 0; bottom: 0; width: 1px; background: #e9ecf0; }
|
||||
.sub-row .box { width: 16px; height: 16px; }
|
||||
.sub-title { flex: 1; font-size: 13px; color: #444; }
|
||||
.sub-row.done .sub-title { color: #a8a8a8; text-decoration: line-through; }
|
||||
.collapse { font-size: 11px; color: var(--color-primary); padding: 2px 0 8px 30px; margin: 0 12px; cursor: pointer; }
|
||||
.sub-add-hint { font-size: 11px; color: #b8bcc4; padding: 0 0 8px 44px; cursor: pointer; }
|
||||
|
||||
/* ---- 状态点 ---- */
|
||||
.sdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
|
||||
.sdot.todo { background: #c9ced6; }
|
||||
.sdot.doing { background: #1677ff; }
|
||||
.sdot.done { background: #00b578; }
|
||||
.sdot.pill { width: auto; height: auto; border-radius: 999px; font-size: 10px; padding: 0 6px; line-height: 15px; color: #fff; }
|
||||
.sdot.pill.todo { background: #a8b0ba; }
|
||||
.sdot.pill.doing { background: #1677ff; }
|
||||
.sdot.pill.done { background: #00b578; }
|
||||
|
||||
/* ---- 看板视图 ---- */
|
||||
.board-wrap {
|
||||
display: flex; gap: 10px; padding: 0 12px 8px; overflow-x: auto; overflow-y: hidden;
|
||||
flex: 1 1 auto; min-height: 0; align-items: stretch;
|
||||
}
|
||||
.bcol { flex: 0 0 186px; display: flex; flex-direction: column; min-height: 0; }
|
||||
.bcol-head { display: flex; align-items: center; gap: 5px; padding: 0 2px 7px; flex-shrink: 0; }
|
||||
.bcol-name { font-size: 12px; font-weight: 700; color: #3d4450; }
|
||||
.bcol-cnt { font-size: 11px; color: #999; background: #e8ebef; border-radius: 999px; padding: 0 6px; line-height: 15px; }
|
||||
.bcol-bind { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 4px; margin-left: auto; }
|
||||
.bcol-bind.off { color: #999; background: #f2f3f5; }
|
||||
.bcol-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 7px; padding-bottom: 8px; }
|
||||
.bcol.dragover .bcol-body { background: #eaf2ff; border-radius: 8px; outline: 1.5px dashed var(--color-primary); }
|
||||
.bcol-empty { font-size: 11px; color: #c0c4cc; text-align: center; padding: 10px 0; }
|
||||
.bcard {
|
||||
background: #fff; border-radius: 8px; padding: 9px 10px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.05); cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.bcard.dragging { opacity: .55; transform: scale(.97) rotate(-1deg); box-shadow: 0 8px 18px rgba(15,23,42,.16); }
|
||||
.bcard.hl { outline: 2px solid var(--color-primary); outline-offset: 1px; }
|
||||
.brow1 { display: flex; align-items: flex-start; gap: 6px; }
|
||||
.btitle { flex: 1; font-size: 12.5px; font-weight: 600; line-height: 1.45; word-break: break-word; }
|
||||
.bstat { display: flex; align-items: center; padding: 2px; cursor: pointer; }
|
||||
.bmeta { display: flex; align-items: center; gap: 6px; margin-top: 6px; font-size: 10.5px; }
|
||||
.bassignee { font-size: 11px; font-weight: 600; color: #4a5160; }
|
||||
.bassignee.none { color: #b8bcc4; font-weight: 400; }
|
||||
.bmembers { font-size: 10px; color: #a7adb6; }
|
||||
.bsub {
|
||||
display: flex; align-items: center; gap: 5px; margin-top: 5px; padding-top: 5px;
|
||||
border-top: 1px dashed #eef0f3; font-size: 11px; color: #5a616b; cursor: pointer;
|
||||
}
|
||||
.bsub .sbox { width: 13px; height: 13px; border-radius: 50%; border: 1.3px solid #d0d0d0; flex-shrink: 0; }
|
||||
.bsub.done .sbox { background: #00b578; border-color: #00b578; }
|
||||
.bsub .stitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bsub.done .stitle { color: #a8a8a8; text-decoration: line-through; }
|
||||
.bmore { font-size: 10px; color: #999; margin-top: 4px; }
|
||||
.bsub-add { font-size: 10.5px; color: #b8bcc4; margin-top: 6px; cursor: pointer; }
|
||||
.badd {
|
||||
background: rgba(255,255,255,.6); border: 1px dashed #d5dae1; border-radius: 8px;
|
||||
padding: 8px; text-align: center; font-size: 11.5px; color: #b8bcc4; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.drag-ghost {
|
||||
position: fixed; z-index: 999; transform: translate(-50%, -50%); pointer-events: none;
|
||||
background: rgba(22,119,255,.92); color: #fff; font-size: 12px; padding: 6px 10px;
|
||||
border-radius: 8px; max-width: 180px; box-shadow: 0 8px 18px rgba(15,23,42,.25);
|
||||
}
|
||||
|
||||
/* ---- 列设置 / 成员页 ---- */
|
||||
.cs-page { position: fixed; inset: 0; z-index: 1000; background: var(--color-bg); display: flex; flex-direction: column; }
|
||||
.cs-page .navbar-sub { display: flex; align-items: center; gap: 10px; padding: 12px 14px; background: #fff; border-bottom: 1px solid var(--color-border); flex-shrink: 0; }
|
||||
.cs-page .navbar-sub .back { font-size: 20px; color: #666; line-height: 1; cursor: pointer; }
|
||||
.cs-page .navbar-sub .title { font-size: 16px; font-weight: 700; }
|
||||
.cs-page .navbar-sub .right { margin-left: auto; display: flex; gap: 6px; }
|
||||
.cs-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-bottom: 20px; }
|
||||
.set-row {
|
||||
display: flex; align-items: center; gap: 10px; background: #fff;
|
||||
padding: 12px 14px; margin-bottom: 1px;
|
||||
}
|
||||
.set-row .sname { font-size: 13px; font-weight: 600; width: 64px; flex-shrink: 0; cursor: pointer; }
|
||||
.set-row .sctrl { flex: 1; display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
.set-hint { font-size: 11px; color: #999; padding: 8px 14px 14px; line-height: 1.7; }
|
||||
.section-h { font-size: 11px; color: #b8bcc4; letter-spacing: .5px; display: flex; align-items: center; gap: 6px; }
|
||||
.drop {
|
||||
border: 1px solid var(--color-border); border-radius: 7px; padding: 4px 6px; font-size: 12px;
|
||||
color: var(--color-text); background: #fff; min-width: 96px;
|
||||
}
|
||||
.drop.off { color: #999; background: #f7f8fa; }
|
||||
.cs-ord { display: flex; flex-direction: column; font-size: 9px; color: #c0c4cc; line-height: 1.1; }
|
||||
.cs-ord span { cursor: pointer; }
|
||||
.cs-del { font-size: 11px; color: #ff3141; cursor: pointer; padding: 2px 4px; }
|
||||
.cs-count { font-size: 11px; color: #999; margin-left: 8px; }
|
||||
|
||||
/* ---- 任务详情 ---- */
|
||||
.sheet-title { font-size: 16px; font-weight: 700; margin-bottom: 4px; word-break: break-word; }
|
||||
.sheet-sub { font-size: 11.5px; color: #999; margin-bottom: 14px; }
|
||||
.field { margin-bottom: 13px; }
|
||||
.field label { display: block; font-size: 12px; color: var(--color-muted); margin-bottom: 6px; }
|
||||
.inp {
|
||||
border: 1px solid var(--color-border); border-radius: 8px; padding: 8px 10px;
|
||||
font-size: 14px; background: #fff; color: var(--color-text); width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
textarea.inp { resize: vertical; }
|
||||
.pick-row { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid var(--color-border); cursor: pointer; }
|
||||
.pick-row:last-child { border-bottom: none; }
|
||||
.pick-row .pn { flex: 1; font-size: 13.5px; }
|
||||
.avatar {
|
||||
width: 26px; height: 26px; border-radius: 50%; background: #dfe6f0; color: #5a6a80;
|
||||
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
.ck { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid #d0d0d0; flex-shrink: 0; position: relative; }
|
||||
.ck.on { background: var(--color-primary); border-color: var(--color-primary); }
|
||||
.ck.on::after {
|
||||
content: ""; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px;
|
||||
border: solid #fff; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg);
|
||||
}
|
||||
.btn-primary { text-align: center; background: var(--color-primary); color: #fff; border-radius: 8px; padding: 10px 0; font-size: 15px; font-weight: 600; margin-top: 18px; cursor: pointer; }
|
||||
.btn-ghost { text-align: center; border: 1px solid var(--color-border); border-radius: 8px; padding: 9px 0; font-size: 15px; color: var(--color-muted); margin-top: 8px; background: #fff; cursor: pointer; }
|
||||
.btn-danger { text-align: center; border: 1px solid #ff3141; border-radius: 8px; padding: 9px 0; font-size: 15px; color: #ff3141; margin-top: 8px; background: #fff; cursor: pointer; }
|
||||
.del-hint { font-size: 11px; font-weight: 400; }
|
||||
.extra-row { display: flex; gap: 8px; margin-top: 8px; align-items: center; }
|
||||
.extra-k { flex: 0 0 82px; }
|
||||
.extra-v { flex: 1; }
|
||||
.extra-del { border: none; background: none; color: #c0c4cc; font-size: 18px; line-height: 1; cursor: pointer; }
|
||||
.extras { margin-top: 4px; }
|
||||
.extras-head {
|
||||
display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600;
|
||||
padding: 9px 10px; background: #f7f8fa; border-radius: 8px; cursor: pointer;
|
||||
}
|
||||
.extras-head .caret { font-size: 10px; color: #999; }
|
||||
.extras-head .cv { margin-left: auto; color: #b8bcc4; font-size: 10px; font-weight: 400; }
|
||||
.extras-body { padding: 4px 0 2px; }
|
||||
.extras-empty { font-size: 11.5px; color: #b8bcc4; padding: 8px 2px; }
|
||||
.extras-add { font-size: 12px; color: var(--color-primary); background: none; border: none; padding: 6px 2px; cursor: pointer; }
|
||||
.tag { font-size: 10px; color: var(--color-primary); background: #f0f5ff; border-radius: 4px; padding: 0 5px; }
|
||||
.tag.grey { color: #999; background: #f2f3f5; }
|
||||
|
||||
/* ---- 空态 / 错误态 ---- */
|
||||
.empty { text-align: center; padding: 70px 20px; color: #999; }
|
||||
.empty .ico {
|
||||
width: 64px; height: 64px; margin: 0 auto 12px; border-radius: 50%; background: #f2f3f5;
|
||||
display: flex; align-items: center; justify-content: center; color: #c4c8ce; font-size: 30px;
|
||||
}
|
||||
.empty .ico.err { background: #fff1f0; color: #ff7875; }
|
||||
.empty p { font-size: 13px; margin-bottom: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user