import { useRef, 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('') // 项目图标更换: 隐藏 file input const iconInputRef = useRef(null) 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 ( setView('main')} onSaved={() => { setView('main'); onChanged?.() }} /> ) } if (view === 'members') { return ( 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 }) } } // 项目图标: 图片裁为 96x96 cover JPEG data URL (保持现有尺寸/圆角由 CSS 承担) function pickIcon() { iconInputRef.current?.click() } function onIconFile(e) { const file = e.target.files?.[0] e.target.value = '' if (!file || !file.type.startsWith('image/')) return const url = URL.createObjectURL(file) const img = new Image() img.onload = async () => { URL.revokeObjectURL(url) const S = 96 const c = document.createElement('canvas') c.width = S c.height = S const ctx = c.getContext('2d') const r = Math.max(S / img.width, S / img.height) const w = img.width * r const h = img.height * r ctx.drawImage(img, (S - w) / 2, (S - h) / 2, w, h) const dataUrl = c.toDataURL('image/jpeg', 0.85) try { await projectApi.update(project.id, { icon: dataUrl }) Toast.show({ content: '图标已更新' }) onChanged?.() } catch (err) { Toast.show({ content: err.message || '图标保存失败' }) } } img.onerror = () => URL.revokeObjectURL(url) img.src = url } async function clearIcon() { try { await projectApi.update(project.id, { icon: null }) Toast.show({ content: '已恢复默认' }) 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 (
‹ 项目管理 · {project.name}
项目内所有成员权限均等、全读写;以下配置对全体成员即时生效。
成员({members.length})
setView('members')}>
{(members[0]?.name || '?').slice(0, 1)}
{members.slice(0, 3).map((m) => m.name).join('、')}{members.length > 3 ? ` 等 ${members.length} 人` : ''} 管理 ›
看板列
setView('columns')}> {(project.columns || []).map((c) => c.name).join(' · ')} 列设置 ›
任务类型
{ setKinds(project.taskKinds || DEFAULT_KINDS); setNewKind(''); setKindEdit(true) }}>
{(project.taskKinds || DEFAULT_KINDS).map((k) => ( {k} ))}
编辑 ›
信息块({notes.length})
{notes.map((n, i) => (
setNoteEdit({ index: i, title: n.title || '', content: n.content || '' })} >
{n.title || '未命名'}
{String(n.content || '').split('\n')[0] || '(空)'}
编辑
))}
setNoteEdit({ index: -1, title: '', content: '' })}> + 添加信息块
项目信息
项目图标
{project.icon ? '' : (project.name || '?').slice(0, 1)}
{project.icon && 恢复} 更换
{ setName(project.name); setRenaming(true) }}> 项目名称 {project.name}
归档项目 {project.archived ? '取消归档' : '归档'}
{!isPersonal && (
删除项目 删除
)} {isOwner && !isPersonal && (
退出项目 退出
)}
个人默认项目不可删除、不可退出(T-PJ-02);退出项目仅创建人可见(T-PJ-07)。
} actions={[ { key: 'c', text: '取消', onClick: () => setRenaming(false) }, { key: 'ok', text: '保存', bold: true, onClick: saveName }, ]} onClose={() => 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): 标题 + 长文本, 独立编辑 */} 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)`, }} >
{noteEdit?.index >= 0 ? '编辑信息块' : '添加信息块'}
用于记录 Readme 等项目相关长文本,每块独立编辑
setNoteEdit((p) => ({ ...(p || {}), title: v }))} />