feat(V0.2): 任务类型标签 —— 项目级自定义 + 任务标记 + 列表/看板展示
Docker Build and Push / build-image (push) Successful in 1m21s
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:
@@ -0,0 +1,3 @@
|
|||||||
|
-- 任务类型标签: 项目级自定义列表 + 任务上的类型
|
||||||
|
ALTER TABLE `Project` ADD COLUMN `taskKinds` JSON NULL;
|
||||||
|
ALTER TABLE `Task` ADD COLUMN `kind` VARCHAR(20) NULL;
|
||||||
@@ -394,6 +394,7 @@ model Project {
|
|||||||
isPersonal Boolean @default(false)
|
isPersonal Boolean @default(false)
|
||||||
archived Boolean @default(false)
|
archived Boolean @default(false)
|
||||||
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
notes Json @default("[]") // 项目信息块 [{ title, content }] (T-PJ-13)
|
||||||
|
taskKinds Json? // 任务类型标签列表 (未配置时服务端回落默认四类)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -447,6 +448,7 @@ model Task {
|
|||||||
|
|
||||||
title String @db.VarChar(200)
|
title String @db.VarChar(200)
|
||||||
description String @default("") @db.Text
|
description String @default("") @db.Text
|
||||||
|
kind String? @db.VarChar(20) // 任务类型标签 (项目 taskKinds 之一)
|
||||||
status String @default("todo") @db.VarChar(8)
|
status String @default("todo") @db.VarChar(8)
|
||||||
doneAt DateTime?
|
doneAt DateTime?
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ export async function ensurePersonalProject(userId) {
|
|||||||
return project
|
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) {
|
function projectSummary(p) {
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -47,6 +61,7 @@ function projectSummary(p) {
|
|||||||
archived: p.archived,
|
archived: p.archived,
|
||||||
createdAt: p.createdAt,
|
createdAt: p.createdAt,
|
||||||
updatedAt: p.updatedAt,
|
updatedAt: p.updatedAt,
|
||||||
|
taskKinds: normTaskKinds(p.taskKinds) || DEFAULT_TASK_KINDS,
|
||||||
memberCount: p._count?.members ?? undefined,
|
memberCount: p._count?.members ?? undefined,
|
||||||
taskCount: p._count?.tasks ?? undefined,
|
taskCount: p._count?.tasks ?? undefined,
|
||||||
}
|
}
|
||||||
@@ -119,7 +134,7 @@ router.put('/:id', async (req, res, next) => {
|
|||||||
const userId = req.user.sub
|
const userId = req.user.sub
|
||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
||||||
const { name, archived, notes } = req.body || {}
|
const { name, archived, notes, taskKinds } = req.body || {}
|
||||||
const data = {}
|
const data = {}
|
||||||
if (name !== undefined) {
|
if (name !== undefined) {
|
||||||
const t = String(name).trim()
|
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} 字` })
|
if (t.length > MAX_NAME) return res.status(400).json({ error: `项目名称不能超过 ${MAX_NAME} 字` })
|
||||||
data.name = t
|
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
|
if (archived !== undefined) data.archived = !!archived
|
||||||
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
// 项目信息块 (T-PJ-13): 任意多个 { title, content }, 整体替换
|
||||||
if (notes !== undefined) {
|
if (notes !== undefined) {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ router.post('/projects/:id/tasks', async (req, res, next) => {
|
|||||||
const project = await loadProjectForUser(req.params.id, userId)
|
const project = await loadProjectForUser(req.params.id, userId)
|
||||||
if (!project) return res.status(404).json({ error: '项目不存在或无权限' })
|
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()
|
const cleanTitle = String(title || '').trim()
|
||||||
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
if (!cleanTitle) return res.status(400).json({ error: '任务标题不能为空' })
|
||||||
if (cleanTitle.length > 200) return res.status(400).json({ error: '任务标题不能超过 200 字' })
|
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,
|
parentId: pid,
|
||||||
title: cleanTitle,
|
title: cleanTitle,
|
||||||
description: String(description || ''),
|
description: String(description || ''),
|
||||||
|
kind: kind ? String(kind).trim().slice(0, 20) : null,
|
||||||
status: firstCol.boundStatus || 'todo',
|
status: firstCol.boundStatus || 'todo',
|
||||||
assigneeId: finalAssignee,
|
assigneeId: finalAssignee,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
@@ -177,6 +178,11 @@ router.put('/tasks/:id', async (req, res, next) => {
|
|||||||
data.title = t
|
data.title = t
|
||||||
}
|
}
|
||||||
if (body.description !== undefined) data.description = String(body.description)
|
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 (body.extras !== undefined) {
|
||||||
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
if (!Array.isArray(body.extras)) return res.status(400).json({ error: '扩展信息格式非法' })
|
||||||
data.extras = body.extras.map((e) => ({
|
data.extras = body.extras.map((e) => ({
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
import { kindColor } from '../../pages/task/kind.js'
|
||||||
|
|
||||||
// 看板卡片 (T-UI-02): 轻量化
|
// 看板卡片 (T-UI-02): 轻量化
|
||||||
// - 标题 + 状态点 (T-ST-09)
|
// - 标题 + 状态点 (T-ST-09)
|
||||||
@@ -66,6 +67,11 @@ export default function TaskCard({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{rest > 0 && <div className="bmore">+{rest} 个子任务</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import StatusDot from './StatusDot.jsx'
|
import StatusDot from './StatusDot.jsx'
|
||||||
|
import { kindColor } from '../../pages/task/kind.js'
|
||||||
|
|
||||||
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
|
const statusLabel = (s) => (s === 'done' ? '完成' : s === 'doing' ? '执行中' : '未开始')
|
||||||
|
|
||||||
@@ -48,9 +49,14 @@ export default function TaskRow({
|
|||||||
<>
|
<>
|
||||||
<div className="task-main">
|
<div className="task-main">
|
||||||
<div className="task-title">{task.title}</div>
|
<div className="task-title">{task.title}</div>
|
||||||
{task.members?.length > 0 && (
|
{(task.kind || task.members?.length > 0) && (
|
||||||
<div className="task-meta">
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
|||||||
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
import useOverlayBack from '../../hooks/useOverlayBack.js'
|
||||||
import ColumnSettings from './ColumnSettings.jsx'
|
import ColumnSettings from './ColumnSettings.jsx'
|
||||||
import ProjectMembers from './ProjectMembers.jsx'
|
import ProjectMembers from './ProjectMembers.jsx'
|
||||||
|
import { DEFAULT_KINDS, kindColor } from './kind.js'
|
||||||
|
|
||||||
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
|
// 项目管理 (T-PJ-11/12): 成员 / 看板列 / 项目信息 三个分区的统一入口
|
||||||
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
|
// onExited: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
|
||||||
@@ -16,6 +17,10 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
const [name, setName] = useState(project.name)
|
const [name, setName] = useState(project.name)
|
||||||
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
|
// 信息块编辑 (T-PJ-13): index=-1 新增, >=0 编辑既有块
|
||||||
const [noteEdit, setNoteEdit] = useState(null)
|
const [noteEdit, setNoteEdit] = useState(null)
|
||||||
|
// 任务类型编辑: 编辑中的类型列表 + 新增输入
|
||||||
|
const [kindEdit, setKindEdit] = useState(false)
|
||||||
|
const [kinds, setKinds] = useState([])
|
||||||
|
const [newKind, setNewKind] = useState('')
|
||||||
|
|
||||||
const members = project.members || []
|
const members = project.members || []
|
||||||
const notes = Array.isArray(project.notes) ? project.notes : []
|
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: '退出该项目?退出后将不再可见。',
|
content: '退出该项目?退出后将不再可见。',
|
||||||
confirmText: '退出',
|
confirmText: '退出',
|
||||||
})
|
})
|
||||||
@@ -167,6 +192,16 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
<span className="link">列设置 ›</span>
|
<span className="link">列设置 ›</span>
|
||||||
</div>
|
</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>
|
<div className="section-h" style={{ margin: '14px 14px 8px' }}>信息块({notes.length})</div>
|
||||||
{notes.map((n, i) => (
|
{notes.map((n, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -228,6 +263,48 @@ export default function ProjectManage({ project, onClose, onChanged, onExited })
|
|||||||
onClose={() => setRenaming(false)}
|
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): 标题 + 长文本, 独立编辑 */}
|
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
|
||||||
<Popup
|
<Popup
|
||||||
visible={!!noteEdit}
|
visible={!!noteEdit}
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export default function ProjectView({ projectId, onProjectChanged, onProjectExit
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
members={project.members || []}
|
members={project.members || []}
|
||||||
tasks={tasks || []}
|
tasks={tasks || []}
|
||||||
|
taskKinds={project.taskKinds}
|
||||||
onClose={() => setOpenTask(null)}
|
onClose={() => setOpenTask(null)}
|
||||||
onSaved={() => { setOpenTask(null); load() }}
|
onSaved={() => { setOpenTask(null); load() }}
|
||||||
onChanged={load}
|
onChanged={load}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
import { ActionSheet, Popup, Toast } from 'antd-mobile'
|
||||||
import { taskApi } from '../../api/task.js'
|
import { taskApi } from '../../api/task.js'
|
||||||
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
import { STATUS_OPTIONS, STATUS_LABEL } from './date.js'
|
||||||
|
import { kindColor } from './kind.js'
|
||||||
import UserPicker from '../../components/task/UserPicker.jsx'
|
import UserPicker from '../../components/task/UserPicker.jsx'
|
||||||
import StatusDot from '../../components/task/StatusDot.jsx'
|
import StatusDot from '../../components/task/StatusDot.jsx'
|
||||||
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
import useKeyboardInset from '../../hooks/useKeyboardInset.js'
|
||||||
@@ -11,13 +12,14 @@ import useOverlayBack from '../../hooks/useOverlayBack.js'
|
|||||||
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
|
// - 状态: 三段式分段控件, 当前项高亮, 点按即切换
|
||||||
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
|
// - 看板列: ActionSheet 选择, 列/状态联动由服务端裁决 (T-BD-*)
|
||||||
// - 转为子任务 / 转为顶层任务 (T-ST-12): 从项目内顶层任务中选父任务
|
// - 转为子任务 / 转为顶层任务 (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(() => ({
|
const [form, setForm] = useState(() => ({
|
||||||
title: task.title || '',
|
title: task.title || '',
|
||||||
description: task.description || '',
|
description: task.description || '',
|
||||||
memberIds: (task.members || []).map((m) => m.userId),
|
memberIds: (task.members || []).map((m) => m.userId),
|
||||||
assigneeId: task.assignee?.id ?? null,
|
assigneeId: task.assignee?.id ?? null,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
kind: task.kind || null,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
}))
|
}))
|
||||||
const [subs, setSubs] = useState(task.children || [])
|
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),
|
memberIds: (task.members || []).map((m) => m.userId),
|
||||||
assigneeId: task.assignee?.id ?? null,
|
assigneeId: task.assignee?.id ?? null,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
|
kind: task.kind || null,
|
||||||
tags: task.tags || [],
|
tags: task.tags || [],
|
||||||
})
|
})
|
||||||
setSubs(task.children || [])
|
setSubs(task.children || [])
|
||||||
@@ -62,6 +65,7 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
assigneeId: form.assigneeId,
|
assigneeId: form.assigneeId,
|
||||||
memberIds: form.memberIds,
|
memberIds: form.memberIds,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
|
kind: form.kind,
|
||||||
tags: form.tags,
|
tags: form.tags,
|
||||||
})
|
})
|
||||||
Toast.show({ content: '已保存' })
|
Toast.show({ content: '已保存' })
|
||||||
@@ -184,6 +188,38 @@ export default function TaskDetail({ task, columns, members, tasks, onClose, onS
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="field">
|
||||||
<label>看板列</label>
|
<label>看板列</label>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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.after { border-bottom: 3px solid var(--color-primary); }
|
||||||
.mv-card.dragover.before { border-top: 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 顶层, 跟随指针 */
|
/* 拖动跟手浮层: 克隆原卡片淡化后挂在 body 顶层, 跟随指针 */
|
||||||
.drag-ghost {
|
.drag-ghost {
|
||||||
position: fixed; left: 0; top: 0; z-index: 1000;
|
position: fixed; left: 0; top: 0; z-index: 1000;
|
||||||
|
|||||||
Reference in New Issue
Block a user