Docker Build and Push / build-image (push) Successful in 4m5s
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 缺失
268 lines
10 KiB
React
268 lines
10 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 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>
|
||
)
|
||
}
|