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:
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user