feat(V0.2): 任务类型标签 —— 项目级自定义 + 任务标记 + 列表/看板展示
Docker Build and Push / build-image (push) Successful in 1m21s

- 数据: Project.taskKinds (JSON, 未配置回落默认 需求/Bug/FEATURE/优化),
  Task.kind (≤20字); 迁移 20261025030000_task_kind
- 服务: 项目 PUT 支持整体替换 taskKinds (去重/上限30/至少1个);
  任务 create/update 支持 kind (空串清空)
- 项目管理: 新增「任务类型」分区, chips 展示 + 弹层编辑 (删除/新增)
- 任务详情: 新增「类型」字段, 项目类型 chips 点选, 可清空
- 展示: 列表行左下与看板卡左下显示彩色小标签
  (需求蓝/Bug红/FEATURE紫/优化橙, 自定义类型灰)
This commit is contained in:
jif2.zhang
2026-10-25 02:53:25 +08:00
parent 2e6cc986a0
commit fb9e2350b3
11 changed files with 192 additions and 7 deletions
@@ -0,0 +1,3 @@
-- 任务类型标签: 项目级自定义列表 + 任务上的类型
ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL;
ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL;
+2
View File
@@ -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?
+23 -1
View File
@@ -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) {
+7 -1
View File
@@ -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) => ({
+6
View File
@@ -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({
</div>
))}
{rest > 0 && <div className="bmore">+{rest} 个子任务</div>}
{task.kind && (
<div style={{ marginTop: 5 }}>
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
</div>
)}
</div>
</div>
)
+8 -2
View File
@@ -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({
<>
<div className="task-main">
<div className="task-title">{task.title}</div>
{task.members?.length > 0 && (
{(task.kind || task.members?.length > 0) && (
<div className="task-meta">
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
{task.kind && (
<span className="kind-tag" style={{ background: kindColor(task.kind) }}>{task.kind}</span>
)}
{task.members?.length > 0 && (
<span className="members">+ {task.members.map((m) => m.name).join('、')}</span>
)}
</div>
)}
</div>
+79 -2
View File
@@ -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 })
<span className="link">列设置 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>任务类型</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
<div className="kind-chip-row" style={{ flex: 1 }}>
{(project.taskKinds || DEFAULT_KINDS).map((k) => (
<span key={k} className="kind-tag" style={{ background: kindColor(k) }}>{k}</span>
))}
</div>
<span className="link">编辑 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
{notes.map((n, i) => (
<div
@@ -228,6 +263,48 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
onClose={() => setRenaming(false)}
/>
{/* 任务类型编辑: chips 删除 + 输入新增 */}
<Popup
visible={kindEdit}
onMaskClick={() => setKindEdit(false)}
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">任务类型</div>
<div className="sheet-sub">任务的分类标签,显示在任务卡片左下角</div>
<div className="kind-chip-row" style={{ marginTop: 12 }}>
{kinds.map((k) => (
<span key={k} className="kind-chip" style={{ background: kindColor(k) }}>
{k}
<span
className="x"
onClick={() => setKinds((p) => p.filter((x) => x !== k))}
>×</span>
</span>
))}
</div>
<div className="extra-row" style={{ display: 'flex', gap: 8, marginTop: 14 }}>
<input
className="inp"
style={{ flex: 1 }}
placeholder="新类型名称"
value={newKind}
maxLength={20}
onChange={(e) => setNewKind(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addKind() }}
/>
<span className="mini-btn solid" style={{ padding: '0 16px', display: 'inline-flex', alignItems: 'center' }} onClick={addKind}>添加</span>
</div>
<div className="btn-primary" onClick={saveKinds}>保存</div>
<div className="btn-ghost" onClick={() => setKindEdit(false)}>取消</div>
</Popup>
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
<Popup
visible={!!noteEdit}
+1
View File
@@ -271,6 +271,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
columns={columns}
members={project.members || []}
tasks={tasks || []}
taskKinds={project.taskKinds}
onClose={() => setOpenTask(null)}
onSaved={() => { setOpenTask(null); load() }}
onChanged={load}
+37 -1
View File
@@ -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
</div>
</div>
{/* 类型: 项目任务类型标签, 可清空 */}
{(taskKinds || []).length > 0 && (
<div className="field">
<label>类型</label>
<div className="kind-chip-row" style={{ flexWrap: 'wrap' }}>
<span
className="kind-tag"
style={{
background: form.kind ? 'transparent' : 'var(--color-primary)',
color: form.kind ? '#999' : '#fff',
border: form.kind ? '1px dashed #ccc' : 'none',
cursor: 'pointer',
}}
onClick={() => setForm((p) => ({ ...p, kind: null }))}
>无</span>
{taskKinds.map((k) => (
<span
key={k}
className="kind-tag"
style={{
background: kindColor(k),
opacity: form.kind === k ? 1 : 0.45,
cursor: 'pointer',
outline: form.kind === k ? '2px solid rgba(22,119,255,.4)' : 'none',
}}
onClick={() => setForm((p) => ({ ...p, kind: p.kind === k ? null : k }))}
>{k}</span>
))}
</div>
</div>
)}
{/* 看板列: 点击换列, 状态随目标列联动 */}
<div className="field">
<label>看板列</label>
+12
View File
@@ -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'
+14
View File
@@ -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;