Files
iboard/src/pages/task/ProjectManage.jsx
T
jif2.zhang 9fea1a5a3a
Docker Build and Push / build-image (push) Successful in 4m5s
fix(V0.2): 二轮评审 6 项 —— BUILD_ID 格式 / 菜单与详情精简 / 拖动重做 / 键盘统一方案
1. BUILD_ID 改为 yyyyMMddHHmmss (去掉中间点), 仍 UTC+8
2. 任务 ⋯ 菜单去掉顶部任务名标题
3. 任务详情去掉底部「拷贝到其他项目」「删除任务」按钮 (⋯ 菜单已有)
4+6. 键盘遮挡统一方案 (重点设计 §7.2):
   - index.html 加 interactive-widget=resizes-content
   - App 级 useKeyboardInsetWatcher 写 --kb-inset (visualViewport+window 双源)
   - 底部弹层统一 .kb-safe 类 (安全区+20px+键盘插入量, dvh 收缩+内部滚动)
   - TaskDetail/UserPicker/CopyTaskPopup/信息块弹层全部迁移, 删除 per-component hook
   - 信息块 textarea 补 focus scrollIntoView 兜底
5. 拖动方案重做 (重点设计 §7.1):
   - 新 useColumnDrag: Pointer Events + setPointerCapture, 长按400ms原位抬起,
     elementFromPoint 高亮目标列, 边缘 rAF 自动横滚; 移除手指跟随浮层(任务名)
   - 列表行长按 500ms → 「移动到列」ActionSheet (不做自由拖动)
   - 移除「拖到卡片转子任务」误触路径, 统一走详情页层级区
   - 删除旧 useLongPressDrag

已验证: 99 文件语法 0 错误、导入 0 缺失
2026-10-24 22:15:52 +08:00

268 lines
10 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 useOverlayBack from '../../hooks/useOverlayBack.js'
import ColumnSettings from './ColumnSettings.jsx'
import ProjectMembers from './ProjectMembers.jsx'
// 项目管理 (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 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() })
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 })
}
}
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' }}>信息块({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)}
/>
{/* 信息块编辑 (T-PJ-13): 标题 + 长文本, 独立编辑 */}
<Popup
visible={!!noteEdit}
onMaskClick={() => setNoteEdit(null)}
position="bottom"
className="kb-safe"
>
<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}
onFocus={(e) => { setTimeout(() => e.target.scrollIntoView({ block: 'center', behavior: 'smooth' }), 300) }}
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>
)
}