feat(V0.2): 标题30字+智能拆分 / 时间标记 MM-dd / 时间词前缀 (T-TK-14~16)
Docker Build and Push / build-image (push) Successful in 1m25s
Docker Build and Push / build-image (push) Successful in 1m25s
- 标题上限收紧至 30 字 (服务端校验+详情编辑框); 快速添加输入框 放宽到 200 字: 超长时在 30 字内分隔符处截断为标题, 完整原文进描述 - Task.markDate (DateTime, 迁移 20261025120000): 详情页 DatePicker 选择/清除, 列表行与看板卡显示 MM-dd 小标签 - 快速添加时间词前缀: 今天/明天/后天/本周/下周/本月/下月 开头 (后跟分隔符) 自动设为时间标记并移出标题; 本周=本周日, 本月=月末 - 需求/功能/数据库设计文档同步更新 T-TK-14~16 与 Task 表字段
This commit is contained in:
@@ -47,6 +47,9 @@ export default function TaskCard({
|
||||
</div>
|
||||
|
||||
<div className="bmeta">
|
||||
{task.markDate && (
|
||||
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||
)}
|
||||
{task.kind && (
|
||||
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||
)}
|
||||
|
||||
@@ -52,6 +52,9 @@ export default function TaskRow({
|
||||
<div className="task-title">{task.title}</div>
|
||||
{/* 无类型/成员也保留该行, 与有标签的行等高 */}
|
||||
<div className="task-meta">
|
||||
{task.markDate && (
|
||||
<span className="date-tag">{task.markDate.slice(5, 10).replace('-', '/')}</span>
|
||||
)}
|
||||
{task.kind && (
|
||||
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ActionSheet, Dialog, 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 { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||
import ListView from './ListView.jsx'
|
||||
import BoardView from './BoardView.jsx'
|
||||
import TaskDetail from './TaskDetail.jsx'
|
||||
@@ -141,12 +142,21 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 新建任务 */
|
||||
async function createTask(title, parentId = null) {
|
||||
const t = String(title || '').trim()
|
||||
if (!t) return
|
||||
/** 新建任务 (快速添加): 时间词前缀 → 时间标记; 超过 30 字在分隔符处截断,
|
||||
* 前段为标题, 余文并入描述 (T-TK-14/16) */
|
||||
async function createTask(rawTitle, parentId = null) {
|
||||
const raw = String(rawTitle || '').trim()
|
||||
if (!raw) return
|
||||
const { markDate, rest } = parseTimePrefix(raw)
|
||||
const { title, desc } = splitTitle(rest)
|
||||
if (!title) return
|
||||
try {
|
||||
await taskApi.create(projectId, { title: t, parentId })
|
||||
await taskApi.create(projectId, {
|
||||
title,
|
||||
description: desc || undefined,
|
||||
markDate: markDate || undefined,
|
||||
parentId,
|
||||
})
|
||||
setAdding(false)
|
||||
load()
|
||||
} catch (e) {
|
||||
@@ -232,7 +242,8 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
||||
<input
|
||||
className="inp"
|
||||
autoFocus
|
||||
placeholder="输入任务标题,回车创建"
|
||||
maxLength={200}
|
||||
placeholder="输入任务标题,回车创建;开头可用 今天/明天/本周/本月 等时间词"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createTask(e.currentTarget.value)
|
||||
if (e.key === 'Escape') setAdding(false)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
||||
import { ActionSheet, Popup, Toast, DatePicker } from 'antd-mobile'
|
||||
import { taskApi } from '../../api/task.js'
|
||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||
import { kindColor } from './kind.js'
|
||||
import { parseTimePrefix, splitTitle } from './quickAdd.js'
|
||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||
@@ -20,12 +21,14 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
kind: task.kind || null,
|
||||
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||
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 [pickDate, setPickDate] = useState(false) // 时间标记日期选择
|
||||
const [parentPick, setParentPick] = useState(false) // 转为子任务: 选择父任务
|
||||
|
||||
// 键盘弹出插入量: 抬升弹层内容, 输入框不被遮挡
|
||||
@@ -50,6 +53,7 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
||||
assigneeId: task.assignee?.id ?? null,
|
||||
status: task.status,
|
||||
kind: task.kind || null,
|
||||
markDate: task.markDate ? task.markDate.slice(0, 10) : null,
|
||||
tags: task.tags || [],
|
||||
})
|
||||
setSubs(task.children || [])
|
||||
@@ -66,6 +70,7 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
||||
memberIds: form.memberIds,
|
||||
status: form.status,
|
||||
kind: form.kind,
|
||||
markDate: form.markDate || null,
|
||||
tags: form.tags,
|
||||
})
|
||||
Toast.show({ content: '已保存' })
|
||||
@@ -111,10 +116,19 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
||||
}
|
||||
|
||||
async function addSub() {
|
||||
const t = newSub.trim()
|
||||
if (!t) return
|
||||
// 子任务快速添加同样支持时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||
const raw = newSub.trim()
|
||||
if (!raw) return
|
||||
const { markDate, rest } = parseTimePrefix(raw)
|
||||
const { title, desc } = splitTitle(rest)
|
||||
if (!title) return
|
||||
try {
|
||||
const r = await taskApi.create(task.projectId, { title: t, parentId: task.id })
|
||||
const r = await taskApi.create(task.projectId, {
|
||||
title,
|
||||
description: desc || undefined,
|
||||
markDate: markDate || undefined,
|
||||
parentId: task.id,
|
||||
})
|
||||
setSubs((prev) => [...prev, r.item])
|
||||
setNewSub('')
|
||||
onChanged?.()
|
||||
@@ -156,7 +170,7 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
||||
<input
|
||||
className="inp"
|
||||
value={form.title}
|
||||
maxLength={200}
|
||||
maxLength={30}
|
||||
onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
@@ -188,6 +202,29 @@ export default function TaskDetail({ task, columns, members, tasks, taskKinds, o
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间标记: 可选日期, 卡片上以 MM-dd 显示 */}
|
||||
<div className="field">
|
||||
<label>时间标记</label>
|
||||
<div className="pick-row">
|
||||
<span className="pn">{form.markDate ? form.markDate.slice(5).replace('-', '/') : '未设置'}</span>
|
||||
{form.markDate && (
|
||||
<span className="link" onClick={() => setForm((p) => ({ ...p, markDate: null }))}>清除</span>
|
||||
)}
|
||||
<span className="link" onClick={() => setPickDate(true)}>选择 ›</span>
|
||||
</div>
|
||||
<DatePicker
|
||||
visible={pickDate}
|
||||
onClose={() => setPickDate(false)}
|
||||
precision="day"
|
||||
value={form.markDate ? new Date(form.markDate + 'T00:00:00') : new Date()}
|
||||
onConfirm={(d) => {
|
||||
const p2 = (n) => String(n).padStart(2, '0')
|
||||
setForm((p) => ({ ...p, markDate: `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}` }))
|
||||
setPickDate(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型: 项目任务类型标签, 可清空 */}
|
||||
{(taskKinds || []).length > 0 && (
|
||||
<div className="field">
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 快速添加的标题解析: 时间词前缀 + 30 字分隔符截断 (T-TK-14/16)
|
||||
export const MAX_TITLE = 30
|
||||
|
||||
// 分隔符: 空格 / 中英文标点 / 斜杠等 —— 截断优先落在分隔符上, 不切词
|
||||
const SEPARATOR = /[\s,,。;;、!!??::“”"'’‘()()\[\]【】\-_/\\|]/
|
||||
|
||||
/**
|
||||
* 长文本拆分: ≤30 字原样; 超长时在 30 字内(且不少于 8 字)找最后一个分隔符截断,
|
||||
* 前段为标题, **完整原文**作为描述 (整体进详情, 不丢内容)。
|
||||
*/
|
||||
export function splitTitle(raw) {
|
||||
const text = String(raw || '').trim()
|
||||
if (text.length <= MAX_TITLE) return { title: text, desc: '' }
|
||||
let cut = -1
|
||||
for (let i = MAX_TITLE; i >= 8; i--) {
|
||||
if (SEPARATOR.test(text[i - 1])) { cut = i; break }
|
||||
}
|
||||
if (cut < 0) cut = MAX_TITLE
|
||||
return { title: text.slice(0, cut).trim(), desc: text }
|
||||
}
|
||||
|
||||
// 时间词 → 日期 (返回 YYYY-MM-DD):
|
||||
// 今天/明天/后天 = 当日 +0/+1/+2
|
||||
// 本周 = 本周日, 下周 = 下周日 (周一定义为一周开始)
|
||||
// 本月 = 本月最后一天, 下月 = 下月最后一天
|
||||
const toISO = (d) => {
|
||||
const p = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
}
|
||||
|
||||
export function dateFromWord(word, now = new Date()) {
|
||||
const day = (n) => { const d = new Date(now); d.setDate(d.getDate() + n); return toISO(d) }
|
||||
const endOfWeek = (offsetWeeks) => {
|
||||
const d = new Date(now)
|
||||
const dow = (d.getDay() + 6) % 7 // 周一=0
|
||||
d.setDate(d.getDate() + offsetWeeks * 7 + (6 - dow))
|
||||
return toISO(d)
|
||||
}
|
||||
const endOfMonth = (offsetMonths) => {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() + offsetMonths + 1, 0)
|
||||
return toISO(d)
|
||||
}
|
||||
switch (word) {
|
||||
case '今天': return day(0)
|
||||
case '明天': return day(1)
|
||||
case '后天': return day(2)
|
||||
case '本周': return endOfWeek(0)
|
||||
case '下周': return endOfWeek(1)
|
||||
case '本月': return endOfMonth(0)
|
||||
case '下月': return endOfMonth(1)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
// 匹配最长的时间词前缀 (后面跟分隔符或直接结束)
|
||||
const TIME_WORDS = ['本周', '下周', '本月', '下月', '今天', '明天', '后天']
|
||||
|
||||
/**
|
||||
* 解析时间词前缀: "明天 交周报" → { markDate: '2026-10-26', rest: '交周报' }
|
||||
* 无匹配时 rest 为原文, markDate 为 null。
|
||||
*/
|
||||
export function parseTimePrefix(raw) {
|
||||
const text = String(raw || '').trim()
|
||||
for (const w of TIME_WORDS) {
|
||||
if (text.startsWith(w)) {
|
||||
const after = text.slice(w.length)
|
||||
if (after === '' || SEPARATOR.test(after[0])) {
|
||||
return { markDate: dateFromWord(w), rest: after.trim() }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { markDate: null, rest: text }
|
||||
}
|
||||
@@ -707,6 +707,12 @@ body.me-safe-top .app-safe-top { height: 0; }
|
||||
.mv-card.dragover.before { border-top: 3px solid var(--color-primary); }
|
||||
|
||||
/* 任务类型小标签: 列表行左下 / 看板卡左下 */
|
||||
.date-tag {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 10px; line-height: 1; font-weight: 600;
|
||||
color: #ad6800; background: #fff7e6; border-radius: 4px; padding: 3px 5px;
|
||||
flex-shrink: 0; white-space: nowrap; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.kind-tag {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 10px; line-height: 1; font-weight: 600; color: #fff;
|
||||
|
||||
Reference in New Issue
Block a user