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;