diff --git a/prisma/migrations/20261025030000_task_kind/migration.sql b/prisma/migrations/20261025030000_task_kind/migration.sql new file mode 100644 index 0000000..4527e01 --- /dev/null +++ b/prisma/migrations/20261025030000_task_kind/migration.sql @@ -0,0 +1,3 @@ +-- 任务类型标签: 项目级自定义列表 + 任务上的类型 +ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL; +ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 28cfc7c..8c7219d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -394,6 +394,7 @@ model Project { isPersonal Boolean @default(false) archived Boolean @default(false) notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13) + taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -447,6 +448,7 @@ model Task { title String @db.VarChar(200) description String @default("") @db.Text + kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一) status String @default("todo") @db.VarChar(8) doneAt DateTime? diff --git a/server/routes/projects.js b/server/routes/projects.js index 4183983..a7b4fdb 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -38,6 +38,20 @@ export async function ensurePersonalProject(userId) { return project } +// 任务类型标签: 项目级自定义, 未配置时用默认四类 +export const DEFAULT_TASK_KINDS = ['需求', 'Bug', 'FEATURE', '优化'] + +export function normTaskKinds(v) { + if (!Array.isArray(v)) return null + const out = [] + for (const k of v) { + const s = String(k || '').trim().slice(0, 20) + if (s && !out.includes(s)) out.push(s) + if (out.length >= 30) break + } + return out +} + function projectSummary(p) { return { id: p.id, @@ -47,6 +61,7 @@ function projectSummary(p) { archived: p.archived, createdAt: p.createdAt, updatedAt: p.updatedAt, + taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS, memberCount: p._count?.members ?? undefined, taskCount: p._count?.tasks ?? undefined, } @@ -119,7 +134,7 @@ router.put('/:id', async (req, res, next) => { const userId = req.user.sub const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { name, archived, notes } = req.body || {} + const { name, archived, notes, taskKinds } = req.body || {} const data = {} if (name !== undefined) { const t = String(name).trim() @@ -127,6 +142,13 @@ router.put('/:id', async (req, res, next) => { if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` }) data.name = t } + // 任务类型标签: 整体替换; 空数组回落默认 + if (taskKinds !== undefined) { + if (!Array.isArray(taskKinds)) return res.status(400).json({ error: '任务类型格式非法' }) + const kinds = normTaskKinds(taskKinds) + if (!kinds?.length) return res.status(400).json({ error: '至少保留一个任务类型' }) + data.taskKinds = kinds + } if (archived !== undefined) data.archived = !!archived // 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换 if (notes !== undefined) { diff --git a/server/routes/tasks.js b/server/routes/tasks.js index 062d6bd..6ac5c71 100644 --- a/server/routes/tasks.js +++ b/server/routes/tasks.js @@ -70,7 +70,7 @@ router.post('/projects/:id/tasks', async (req, res, next) => { const project = await loadProjectForUser(req.params.id, userId) if (!project) return res.status(404).json({ error: '项目不存在或无权限' }) - const { title, description = '', parentId, assigneeId, memberIds = [], tags = [] } = req.body || {} + const { title, description = '', parentId, assigneeId, memberIds = [], tags = [], kind } = req.body || {} const cleanTitle = String(title || '').trim() if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' }) if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' }) @@ -107,6 +107,7 @@ router.post('/projects/:id/tasks', async (req, res, next) => { parentId: pid, title: cleanTitle, description: String(description || ''), + kind: kind ? String(kind).trim().slice(0, 20) : null, status: firstCol.boundStatus || 'todo', assigneeId: finalAssignee, creatorId: userId, @@ -177,6 +178,11 @@ router.put('/tasks/:id', async (req, res, next) => { data.title = t } if (body.description !== undefined) data.description = String(body.description) + // 任务类型标签: null 清空, 否则截断保存 + if (body.kind !== undefined) { + const k = String(body.kind || '').trim().slice(0, 20) + data.kind = k || null + } if (body.extras !== undefined) { if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' }) data.extras = body.extras.map((e) => ({ diff --git a/src/components/task/TaskCard.jsx b/src/components/task/TaskCard.jsx index d07211b..1e92bc3 100644 --- a/src/components/task/TaskCard.jsx +++ b/src/components/task/TaskCard.jsx @@ -1,4 +1,5 @@ import StatusDot from './StatusDot.jsx' +import { kindColor } from '../../pages/task/kind.js' // 看板卡片 (T-UI-02): 轻量化 // - 标题 + 状态点 (T-ST-09) @@ -66,6 +67,11 @@ export default function TaskCard({ ))} {rest > 0 &&
+{rest} 个子任务
} + {task.kind && ( +
+ {task.kind} +
+ )} ) diff --git a/src/components/task/TaskRow.jsx b/src/components/task/TaskRow.jsx index a7cc302..7bf9e68 100644 --- a/src/components/task/TaskRow.jsx +++ b/src/components/task/TaskRow.jsx @@ -1,4 +1,5 @@ import StatusDot from './StatusDot.jsx' +import { kindColor } from '../../pages/task/kind.js' const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始') @@ -48,9 +49,14 @@ export default function TaskRow({ <>
{task.title}
- {task.members?.length > 0 && ( + {(task.kind || task.members?.length > 0) && (
- + {task.members.map((m) => m.name).join('、')} + {task.kind && ( + {task.kind} + )} + {task.members?.length > 0 && ( + + {task.members.map((m) => m.name).join('、')} + )}
)}
diff --git a/src/pages/task/ProjectManage.jsx b/src/pages/task/ProjectManage.jsx index 30c9b24..a14d4a9 100644 --- a/src/pages/task/ProjectManage.jsx +++ b/src/pages/task/ProjectManage.jsx @@ -6,6 +6,7 @@ import useKeyboardInset from '../../hooks/useKeyboardInset.js' import useOverlayBack from '../../hooks/useOverlayBack.js' import ColumnSettings from './ColumnSettings.jsx' import ProjectMembers from './ProjectMembers.jsx' +import { DEFAULT_KINDS, kindColor } from './kind.js' // 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口 // onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目) @@ -16,6 +17,10 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) const [name, setName] = useState(project.name) // 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块 const [noteEdit, setNoteEdit] = useState(null) + // 任务类型编辑: 编辑中的类型列表 + 新增输入 + const [kindEdit, setKindEdit] = useState(false) + const [kinds, setKinds] = useState([]) + const [newKind, setNewKind] = useState('') const members = project.members || [] const notes = Array.isArray(project.notes) ? project.notes : [] @@ -123,8 +128,28 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) } } - async function quit() { - const ok = await Dialog.confirm({ + // 任务类型: 添加 / 删除 (至少保留一个) + function addKind() { + const k = newKind.trim().slice(0, 20) + if (!k) return + if (kinds.includes(k)) { Toast.show({ content: '该类型已存在' }); return } + setKinds((p) => [...p, k]) + setNewKind('') + } + + async function saveKinds() { + if (!kinds.length) { Toast.show({ content: '至少保留一个任务类型' }); return } + try { + await projectApi.update(project.id, { taskKinds: kinds }) + Toast.show({ content: '已保存' }) + setKindEdit(false) + onChanged?.() + } catch (e) { + Toast.show({ content: e.message }) + } + } + + async function quit() { const ok = await Dialog.confirm({ content: '退出该项目?退出后将不再可见。', confirmText: '退出', }) @@ -167,6 +192,16 @@ export default function ProjectManage({ project, onClose, onChanged, onExited }) 列设置 › +
任务类型
+
{ setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}> +
+ {(project.taskKinds || DEFAULT_KINDS).map((k) => ( + {k} + ))} +
+ 编辑 › +
+
信息块({notes.length})
{notes.map((n, i) => (
setRenaming(false)} /> + {/* 任务类型编辑: chips 删除 + 输入新增 */} + setKindEdit(false)} + bodyStyle={{ + borderTopLeftRadius: 12, + borderTopRightRadius: 12, + padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`, + }} + > +
任务类型
+
任务的分类标签,显示在任务卡片左下角
+ +
+ {kinds.map((k) => ( + + {k} + setKinds((p) => p.filter((x) => x !== k))} + >× + + ))} +
+ +
+ setNewKind(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') addKind() }} + /> + 添加 +
+ +
保存
+
setKindEdit(false)}>取消
+
+ {/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */} setOpenTask(null)} onSaved={() => { setOpenTask(null); load() }} onChanged={load} diff --git a/src/pages/task/TaskDetail.jsx b/src/pages/task/TaskDetail.jsx index 60abff7..d7fee02 100644 --- a/src/pages/task/TaskDetail.jsx +++ b/src/pages/task/TaskDetail.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { ActionSheet, Popup, Toast } from 'antd-mobile' import { taskApi } from '../../api/task.js' import { STATUS_OPTIONS, STATUS_LABEL } from './date.js' +import { kindColor } from './kind.js' import UserPicker from '../../components/task/UserPicker.jsx' import StatusDot from '../../components/task/StatusDot.jsx' import useKeyboardInset from '../../hooks/useKeyboardInset.js' @@ -11,13 +12,14 @@ import useOverlayBack from '../../hooks/useOverlayBack.js' // - 状态: 三段式分段控件, 当前项高亮, 点按即切换 // - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*) // - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务 -export default function TaskDetail({ task, columns, members, tasks, onClose, onSaved, onChanged }) { +export default function TaskDetail({ task, columns, members, tasks, taskKinds, onClose, onSaved, onChanged }) { const [form, setForm] = useState(() => ({ title: task.title || '', description: task.description || '', memberIds: (task.members || []).map((m) => m.userId), assigneeId: task.assignee?.id ?? null, status: task.status, + kind: task.kind || null, tags: task.tags || [], })) const [subs, setSubs] = useState(task.children || []) @@ -47,6 +49,7 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS memberIds: (task.members || []).map((m) => m.userId), assigneeId: task.assignee?.id ?? null, status: task.status, + kind: task.kind || null, tags: task.tags || [], }) setSubs(task.children || []) @@ -62,6 +65,7 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS assigneeId: form.assigneeId, memberIds: form.memberIds, status: form.status, + kind: form.kind, tags: form.tags, }) Toast.show({ content: '已保存' }) @@ -184,6 +188,38 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
+ {/* 类型: 项目任务类型标签, 可清空 */} + {(taskKinds || []).length > 0 && ( +
+ +
+ setForm((p) => ({ ...p, kind: null }))} + >无 + {taskKinds.map((k) => ( + setForm((p) => ({ ...p, kind: p.kind === k ? null : k }))} + >{k} + ))} +
+
+ )} + {/* 看板列: 点击换列, 状态随目标列联动 */}
diff --git a/src/pages/task/kind.js b/src/pages/task/kind.js new file mode 100644 index 0000000..bccdb53 --- /dev/null +++ b/src/pages/task/kind.js @@ -0,0 +1,12 @@ +// 任务类型标签 (项目级自定义, 默认四类) +const KIND_COLORS = { + 需求: '#1677ff', + Bug: '#ff3141', + bug: '#ff3141', + FEATURE: '#a855f7', + 优化: '#ff8f1f', +} + +export const DEFAULT_KINDS = ['需求', 'Bug', 'FEATURE', '优化'] + +export const kindColor = (k) => KIND_COLORS[k] || '#8a919c' diff --git a/src/styles/globals.css b/src/styles/globals.css index 5069952..7ca2479 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -699,6 +699,20 @@ body.me-safe-top .app-safe-top { height: 0; } .mv-card.dragover.after { border-bottom: 3px solid var(--color-primary); } .mv-card.dragover.before { border-top: 3px solid var(--color-primary); } +/* 任务类型小标签: 列表行左下 / 看板卡左下 */ +.kind-tag { + display: inline-flex; align-items: center; + font-size: 10px; line-height: 1; font-weight: 600; color: #fff; + border-radius: 4px; padding: 3px 5px; flex-shrink: 0; white-space: nowrap; +} +/* 项目管理: 任务类型编辑 chips */ +.kind-chip-row { display: flex; flex-wrap: wrap; gap: 8px; } +.kind-chip { + display: inline-flex; align-items: center; gap: 5px; + font-size: 12.5px; color: #fff; border-radius: 999px; padding: 5px 6px 5px 11px; +} +.kind-chip .x { font-size: 13px; color: rgba(255,255,255,.75); cursor: pointer; padding: 0 4px; } + /* 拖动跟手浮层: 克隆原卡片淡化后挂在 body 顶层, 跟随指针 */ .drag-ghost { position: fixed; left: 0; top: 0; z-index: 1000;