Docker Build and Push / build-image (push) Successful in 1m22s
- 快速添加改 3 行 textarea, Enter 创建/Shift+Enter 换行/Esc 关闭, 焦点离开且无内容时自动收起 - 任务 ⋯ ActionSheet 点选动作后 handler.close() 收回菜单 - Project.icon (TEXT data URL, 迁移 20261025130000): 项目管理可选图片, 客户端裁为 96x96 cover JPEG (~15KB); 项目列表/最近项目/管理页图标位 显示图片, 保持原尺寸与圆角; 可恢复默认首字色块
415 lines
16 KiB
React
415 lines
16 KiB
React
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>
|
||
)
|
||
}
|