Files
iboard/src/pages/task/ProjectManage.jsx
T
jif2.zhang fb9e2350b3
Docker Build and Push / build-image (push) Successful in 1m21s
feat(V0.2): 任务类型标签 —— 项目级自定义 + 任务标记 + 列表/看板展示
- 数据: Project.taskKinds (JSON, 未配置回落默认 需求/Bug/FEATURE/优化),
  Task.kind (≤20字); 迁移 20261025030000_task_kind
- 服务: 项目 PUT 支持整体替换 taskKinds (去重/上限30/至少1个);
  任务 create/update 支持 kind (空串清空)
- 项目管理: 新增「任务类型」分区, chips 展示 + 弹层编辑 (删除/新增)
- 任务详情: 新增「类型」字段, 项目类型 chips 点选, 可清空
- 展示: 列表行左下与看板卡左下显示彩色小标签
  (需求蓝/Bug红/FEATURE紫/优化橙, 自定义类型灰)
2026-10-25 02:53:25 +08:00

353 lines
14 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { Dialog, Input, Popup, Toast } from 'antd-mobile'
import { projectApi } from '../../api/task.js'
import { useAuth } from '../../auth/AuthContext.jsx'
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: 项目被删除/退出/归档后, 上层应离开该项目(回落个人默认项目)
export default function ProjectManage({ project, onClose, onChanged, onExited }) {
const { user } = useAuth()
const [view, setView] = useState('main') // main | columns | members
const [renaming, setRenaming] = useState(false)
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 : []
const isOwner = user?.id === project.ownerId
const isPersonal = !!project.isPersonal
// 返回键: 内层视图(列设置/成员)先回主区, 主区再关闭整个项目管理
useOverlayBack(true, () => { if (view !== 'main') setView('main'); else onClose() })
// 键盘弹出插入量: 信息块编辑弹层用
const kb = useKeyboardInset()
if (view === 'columns') {
return (
<ColumnSettings
projectId={project.id}
columns={project.columns || []}
onClose={() => setView('main')}
onSaved={() => { setView('main'); onChanged?.() }}
/>
)
}
if (view === 'members') {
return (
<ProjectMembers
project={project}
onClose={() => setView('main')}
onChanged={onChanged}
/>
)
}
async function saveName() {
const t = name.trim()
if (!t) { Toast.show({ content: '项目名称不能为空' }); return }
try {
await projectApi.update(project.id, { name: t })
Toast.show({ content: '已改名' })
setRenaming(false)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function toggleArchive() {
const toArchive = !project.archived
const ok = await Dialog.confirm({
content: toArchive ? '归档该项目?归档后从进行中列表移出,可在归档区找回。' : '取消归档该项目?',
confirmText: toArchive ? '归档' : '取消归档',
})
if (!ok) return
try {
await projectApi.update(project.id, { archived: toArchive })
Toast.show({ content: toArchive ? '已归档' : '已恢复' })
if (toArchive) onExited?.()
else onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function remove() {
const ok = await Dialog.confirm({
content: `删除项目「${project.name}」?其中的任务、看板列将一并删除,不可恢复。`,
confirmText: '删除',
})
if (!ok) return
try {
await projectApi.remove(project.id)
Toast.show({ content: '已删除' })
onExited?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
// 信息块保存 (T-PJ-13): 整体替换 notes 数组
async function saveNote() {
const { index, title, content } = noteEdit
const next = notes.map((n) => ({ title: n.title || '', content: n.content || '' }))
const block = { title: String(title || '').trim(), content: String(content || '') }
if (!block.title && !block.content) { Toast.show({ content: '请填写标题或内容' }); return }
if (index >= 0) next[index] = block
else next.push(block)
try {
await projectApi.update(project.id, { notes: next })
Toast.show({ content: '已保存' })
setNoteEdit(null)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
async function removeNote() {
const { index } = noteEdit
const next = notes.filter((_, i) => i !== index)
try {
await projectApi.update(project.id, { notes: next })
Toast.show({ content: '已删除' })
setNoteEdit(null)
onChanged?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
// 任务类型: 添加 / 删除 (至少保留一个)
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: '退出',
})
if (!ok) return
try {
await projectApi.removeMember(project.id, user.id)
Toast.show({ content: '已退出' })
onExited?.()
} catch (e) {
Toast.show({ content: e.message })
}
}
return (
<div className="cs-page">
<div className="navbar-sub">
<span className="back" onClick={onClose}>‹</span>
<span className="title">项目管理 · {project.name}</span>
</div>
<div className="cs-body">
<div className="set-hint" style={{ paddingTop: 12 }}>
项目内所有成员权限均等、全读写;以下配置对全体成员即时生效。
</div>
<div className="section-h" style={{ margin: '6px 14px 8px' }}>成员({members.length})</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('members')}>
<div className="avatar">{(members[0]?.name || '?').slice(0, 1)}</div>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>
{members.slice(0, 3).map((m) => m.name).join('、')}{members.length > 3 ? ` 等 ${members.length} 人` : ''}
</span>
<span className="link">管理 ›</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>看板列</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => setView('columns')}>
<span className="pn" style={{ flex: 1, fontSize: 13, color: '#999' }}>
{(project.columns || []).map((c) => c.name).join(' · ')}
</span>
<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
className="set-row note-row"
key={i}
style={{ padding: '11px 14px', alignItems: 'flex-start' }}
onClick={() => setNoteEdit({ index: i, title: n.title || '', content: n.content || '' })}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="note-title">{n.title || '未命名'}</div>
<div className="note-preview">{String(n.content || '').split('\n')[0] || '(空)'}</div>
</div>
<span className="link" style={{ flexShrink: 0, marginTop: 2 }}>编辑</span>
</div>
))}
<div className="set-row" style={{ padding: '11px 14px' }} onClick={() => setNoteEdit({ index: -1, title: '', content: '' })}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: 'var(--color-primary)' }}>+ 添加信息块</span>
</div>
<div className="section-h" style={{ margin: '14px 14px 8px' }}>项目信息</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={() => { setName(project.name); setRenaming(true) }}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目名称</span>
<span style={{ fontSize: 12.5, color: '#999' }}>{project.name}</span>
</div>
<div className="set-row" style={{ padding: '12px 14px' }} onClick={toggleArchive}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>归档项目</span>
<span className="link">{project.archived ? '取消归档' : '归档'}</span>
</div>
{!isPersonal && (
<div className="set-row" style={{ padding: '12px 14px' }} onClick={remove}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>删除项目</span>
<span className="link danger">删除</span>
</div>
)}
{isOwner && !isPersonal && (
<div className="set-row" style={{ padding: '12px 14px' }} onClick={quit}>
<span className="pn" style={{ flex: 1, fontSize: 13.5, color: '#ff3141' }}>退出项目</span>
<span className="link danger">退出</span>
</div>
)}
<div className="set-hint">
个人默认项目不可删除、不可退出(T-PJ-02);退出项目仅创建人可见(T-PJ-07)。
</div>
</div>
<Dialog
visible={renaming}
title="项目名称"
content={
<div style={{ marginTop: 8 }}>
<Input placeholder="项目名称" value={name} onChange={setName} maxLength={20} />
</div>
}
actions={[
{ key: 'c', text: '取消', onClick: () => setRenaming(false) },
{ key: 'ok', text: '保存', bold: true, onClick: saveName },
]}
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}
onMaskClick={() => setNoteEdit(null)}
position="bottom"
bodyStyle={{
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
maxHeight: '86vh',
overflowY: 'auto',
padding: `16px 16px calc(env(safe-area-inset-bottom, 0px) + 20px + ${kb}px)`,
}}
>
<div className="sheet-title">{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}</div>
<div className="sheet-sub">用于记录 Readme 等项目相关长文本,每块独立编辑</div>
<div className="field">
<label>标题</label>
<Input
placeholder="如:项目说明 / Readme"
value={noteEdit?.title || ''}
maxLength={50}
onChange={(v) => setNoteEdit((p) => ({ ...(p || {}), title: v }))}
/>
</div>
<div className="field">
<label>内容</label>
<textarea
className="inp"
rows={10}
placeholder="支持多行长文本…"
value={noteEdit?.content || ''}
onChange={(e) => setNoteEdit((p) => ({ ...(p || {}), content: e.target.value }))}
/>
</div>
<div className="btn-primary" onClick={saveNote}>保存</div>
{noteEdit?.index >= 0 && (
<div className="btn-danger" onClick={removeNote}>删除此信息块</div>
)}
<div className="btn-ghost" onClick={() => setNoteEdit(null)}>取消</div>
</Popup>
</div>
)
}