Files
iboard/src/pages/task/ProjectManage.jsx
T
jif2.zhang cee21a39d9
Docker Build and Push / build-image (push) Successful in 1m22s
feat(V0.2): 快速添加输入框三行/失焦收起; ⋯菜单点选即收; 项目图标可设图片
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭,
  焦点离开且无内容时自动收起
- 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单
- Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片,
  客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位
  显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
2026-10-25 12:02:29 +08:00

415 lines
16 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 { 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 (
<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 })
}
}
// 项目图标: 图片裁为 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 (
<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' }}>
<span className="pn" style={{ flex: 1, fontSize: 13.5 }}>项目图标</span>
<div
className="pico"
style={{
width: 34, height: 34, borderRadius: 9, flexShrink: 0,
background: project.icon ? `url(${project.icon}) center/cover no-repeat` : '#dfe6f0',
color: '#5a6a80', cursor: 'pointer',
}}
onClick={pickIcon}
>
{project.icon ? '' : (project.name || '?').slice(0, 1)}
</div>
{project.icon && <span className="link" style={{ marginLeft: 10 }} onClick={clearIcon}>恢复</span>}
<span className="link" style={{ marginLeft: 10 }} onClick={pickIcon}>更换</span>
<input ref={iconInputRef} type="file" accept="image/*" hidden onChange={onIconFile} />
</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>
)
}