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紫/优化橙, 自定义类型灰)
353 lines
14 KiB
React
353 lines
14 KiB
React
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>
|
||
)
|
||
}
|