maestro(tsk_tqxHX6NRQ3iA): 前端 NewTaskPanel:剪贴板粘贴图片 + 拖拽上传 + 统一 files 状态 + 缩略图预览 + 单个删除 + 客户端去重 + 5 语 i18n

This commit is contained in:
maestro
2026-06-30 01:36:43 +08:00
parent 3ffdf411fc
commit 65cd5add5c
2 changed files with 125 additions and 10 deletions
+120 -10
View File
@@ -354,13 +354,101 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
);
}
// 新建任务面板的附件区样式(dropzone 高亮 / 缩略图 chip / 删除角标),一次性注入避免污染全局
function ensureNewTaskCss() {
if (document.getElementById('maestro-kit-newtask-css')) return;
const s = document.createElement('style');
s.id = 'maestro-kit-newtask-css';
s.textContent = `
.m-nt-drop { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--line); border-radius: var(--radius-sm, 4px); background: var(--bg-deep); transition: border-color .12s, background .12s; }
.m-nt-drop.is-over { border-color: var(--green-dim); background: var(--panel-2); }
.m-nt-hint { font-size: 10.5px; color: var(--faint); letter-spacing: .04em; }
.m-nt-hint.is-over { color: var(--green); }
.m-nt-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.m-nt-chip { position: relative; width: 92px; display: flex; flex-direction: column; gap: 3px; }
.m-nt-thumb { width: 92px; height: 64px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel-2); overflow: hidden; display: flex; align-items: center; justify-content: center; }
.m-nt-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.m-nt-ext { font-family: var(--mono); font-size: 12px; color: var(--cyan); text-transform: uppercase; letter-spacing: .06em; }
.m-nt-name { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.m-nt-size { font-size: 9.5px; color: var(--faint); font-family: var(--mono); }
.m-nt-del { position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; padding: 0; line-height: 1; border: 1px solid var(--line); border-radius: 50%; background: var(--panel); color: var(--muted); cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: color .12s, border-color .12s, background .12s; }
.m-nt-del:hover { color: var(--red); border-color: var(--red-dim); background: var(--panel-2); }
`;
document.head.appendChild(s);
}
// 三来源(选择/粘贴/拖拽)统一去重键:name+size+lastModified
function ntFileKey(f) { return (f.name || '') + '' + f.size + '' + f.lastModified; }
// 由 MIME 推断扩展名(剪贴板图片常无文件名)
function ntExtFromType(type) {
if (!type) return '';
const sub = String(type).split('/')[1] || '';
const m = { jpeg: 'jpg', svg: 'svg', 'svg+xml': 'svg' };
return sub ? '.' + (m[sub] || sub) : '';
}
// 为无名文件(粘贴的截图)补一个稳定文件名
function ntNamed(f, i) {
if (f.name) return f;
const name = 'pasted-' + Date.now() + (i ? '-' + i : '') + ntExtFromType(f.type);
try { return new File([f], name, { type: f.type, lastModified: f.lastModified }); } catch { return f; }
}
// chip 角标显示的扩展名文字
function ntExtLabel(f) {
const fromName = f.name && f.name.includes('.') ? f.name.split('.').pop() : '';
return (fromName || ntExtFromType(f.type).replace(/^\./, '') || 'file').toUpperCase();
}
function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
ensureNewTaskCss();
const { Button, Input, Select, ComplexitySeg } = window.MaestroDesignSystem_a6a290;
const [title, setTitle] = React.useState('');
const [complexity, setComplexity] = React.useState('auto');
const [priority, setPriority] = React.useState('1');
const [parentId, setParentId] = React.useState('');
const [files, setFiles] = React.useState([]);
const [dragOver, setDragOver] = React.useState(false);
// 图片缩略图 objectURL 缓存(按去重键),卸载/移除时显式 revoke 防泄漏
const urlMapRef = React.useRef(new Map());
const thumbUrl = (f) => {
if (!f.type || !f.type.startsWith('image/')) return null;
const key = ntFileKey(f);
const map = urlMapRef.current;
if (!map.has(key)) map.set(key, URL.createObjectURL(f));
return map.get(key);
};
// 组件卸载时释放全部 objectURL
React.useEffect(() => () => {
urlMapRef.current.forEach((u) => URL.revokeObjectURL(u));
urlMapRef.current.clear();
}, []);
// 合并新文件并按复合键去重(剪贴板无名图先补名)
const addFiles = (incoming) => {
if (!incoming || !incoming.length) return;
const named = Array.from(incoming).map((f, i) => ntNamed(f, i));
setFiles((prev) => {
const seen = new Set(prev.map(ntFileKey));
const merged = prev.slice();
named.forEach((f) => { const k = ntFileKey(f); if (!seen.has(k)) { seen.add(k); merged.push(f); } });
return merged;
});
};
const removeFile = (key) => {
setFiles((prev) => prev.filter((f) => ntFileKey(f) !== key));
const map = urlMapRef.current;
if (map.has(key)) { URL.revokeObjectURL(map.get(key)); map.delete(key); }
};
const onPaste = (e) => {
const items = (e.clipboardData && e.clipboardData.items) || [];
const imgs = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.kind === 'file' && it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) imgs.push(f);
}
}
if (imgs.length) { e.preventDefault(); addFiles(imgs); }
};
const submit = (e) => {
e.preventDefault();
if (!title.trim()) return;
@@ -372,7 +460,7 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
walk(tasks, '');
return (
<form style={{ background: 'var(--panel)', border: '1px solid var(--line)', borderRadius: 6, padding: 14, marginBottom: 14 }}
onSubmit={submit}>
onSubmit={submit} onPaste={onPaste}>
<div style={{ display: 'flex', gap: 14, alignItems: 'flex-end', flexWrap: 'wrap' }}>
<span style={{ flex: 1, minWidth: 320, display: 'flex' }}><Input label={t.titleLabel} required placeholder={t.titlePlaceholder} value={title} onChange={setTitle} style={{ width: '100%' }} /></span>
</div>
@@ -389,16 +477,38 @@ function NewTaskPanel({ tasks, onCancel, onCreate, t }) {
</span>
</div>
<div style={{ marginTop: 12 }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: 4, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.attachLabel || '附件 · 图片/文件(可选)'}</span>
<input type="file" multiple onChange={(e) => setFiles([...e.target.files])}
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
</label>
{files.length ? (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--faint)' }}>
{files.map((f) => f.name).join(' · ')}
<label style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: 'var(--muted)', letterSpacing: '.08em' }}>
<span>{t.attachLabel}</span>
<div className={'m-nt-drop' + (dragOver ? ' is-over' : '')}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragEnter={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOver(false); }}
onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); setDragOver(false); }}>
<input type="file" multiple
onChange={(e) => { addFiles(e.target.files); e.target.value = ''; }}
style={{ fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--ink)' }} />
<span className={'m-nt-hint' + (dragOver ? ' is-over' : '')}>{dragOver ? t.dropHint : t.pasteHint}</span>
{files.length ? (
<div className="m-nt-grid">
{files.map((f) => {
const key = ntFileKey(f);
const url = thumbUrl(f);
return (
<div key={key} className="m-nt-chip">
<button type="button" className="m-nt-del" title={t.removeFile}
onClick={() => removeFile(key)}>×</button>
<div className="m-nt-thumb">
{url ? <img src={url} alt={f.name} /> : <span className="m-nt-ext">{ntExtLabel(f)}</span>}
</div>
<span className="m-nt-name" title={f.name}>{f.name}</span>
<span className="m-nt-size">{(f.size / 1024).toFixed(0)}KB</span>
</div>
);
})}
</div>
) : null}
</div>
) : null}
</label>
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14, paddingTop: 12, borderTop: '1px solid var(--line-soft)' }}>
<Button onClick={onCancel}>{t.cancel}</Button>
+5
View File
@@ -40,6 +40,7 @@ window.MAESTRO_I18N = {
taskSection: '任务树', newTask: '+ 新建任务', titleLabel: '标题', titlePlaceholder: '要做什么',
complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级',
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: '创建任务', cancel: '取消',
attachLabel: '附件 · 图片/文件(可选)', dropHint: '拖拽文件到此处,或粘贴截图', removeFile: '移除', pasteHint: '支持 ⌘V 粘贴图片',
depsWait: '待依赖', specLabel: '改动方案(SPEC', opsLabel: '将执行的操作(OPERATIONS',
pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)',
emptyTasks: '该项目暂无任务 —— 点「+ 新建任务」或经 MCP 创建', empty: '空',
@@ -90,6 +91,7 @@ window.MAESTRO_I18N = {
taskSection: 'Task tree', newTask: '+ New task', titleLabel: 'Title', titlePlaceholder: 'What needs doing',
complexity: 'Complexity', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(top level)', priority: 'Priority',
p0: 'P0 · high', p1: 'P1 · medium', p2: 'P2 · low', create: 'Create task', cancel: 'Cancel',
attachLabel: 'Attachments · images/files (optional)', dropHint: 'Drop files here, or paste a screenshot', removeFile: 'Remove', pasteHint: '⌘V to paste images',
depsWait: 'deps', specLabel: 'CHANGE SPEC', opsLabel: 'PLANNED OPERATIONS',
pendingDoc: 'Awaiting Claude Code output (written via MCP, then submitted for review)',
emptyTasks: 'No tasks in this project — hit "+ New task" or create via MCP', empty: 'EMPTY',
@@ -140,6 +142,7 @@ window.MAESTRO_I18N = {
taskSection: 'Árbol de tareas', newTask: '+ Nueva tarea', titleLabel: 'Título', titlePlaceholder: 'Qué hay que hacer',
complexity: 'Complejidad', cplxAuto: 'AUTO', parentTask: 'Padre', topLevel: '(raíz)', priority: 'Prioridad',
p0: 'P0 · alta', p1: 'P1 · media', p2: 'P2 · baja', create: 'Crear tarea', cancel: 'Cancelar',
attachLabel: 'Adjuntos · imágenes/archivos (opcional)', dropHint: 'Suelta archivos aquí o pega una captura', removeFile: 'Quitar', pasteHint: '⌘V para pegar imágenes',
depsWait: 'deps', specLabel: 'SPEC DE CAMBIOS', opsLabel: 'OPERACIONES PREVISTAS',
pendingDoc: 'Esperando salida de Claude Code (vía MCP, luego a revisión)',
emptyTasks: 'Sin tareas — pulsa "+ Nueva tarea" o crea vía MCP', empty: 'VACÍO',
@@ -190,6 +193,7 @@ window.MAESTRO_I18N = {
taskSection: 'タスクツリー', newTask: '+ 新規タスク', titleLabel: 'タイトル', titlePlaceholder: '何をしますか',
complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度',
p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: 'タスク作成', cancel: 'キャンセル',
attachLabel: '添付 · 画像/ファイル(任意)', dropHint: 'ここにファイルをドロップ、または貼り付け', removeFile: '削除', pasteHint: '⌘Vで画像を貼り付け',
depsWait: '依存待ち', specLabel: 'SPEC · 変更案', opsLabel: 'OPERATIONS · 操作',
pendingDoc: 'Claude Code の出力待ち(MCP 経由で書き込み後レビューへ)',
emptyTasks: 'タスクなし — 「+ 新規タスク」または MCP で作成', empty: '空',
@@ -240,6 +244,7 @@ window.MAESTRO_I18N = {
taskSection: 'Arbre des tâches', newTask: '+ Nouvelle tâche', titleLabel: 'Titre', titlePlaceholder: 'Que faut-il faire',
complexity: 'Complexité', cplxAuto: 'AUTO', parentTask: 'Parent', topLevel: '(racine)', priority: 'Priorité',
p0: 'P0 · haute', p1: 'P1 · moyenne', p2: 'P2 · basse', create: 'Créer la tâche', cancel: 'Annuler',
attachLabel: 'Pièces jointes · images/fichiers (facultatif)', dropHint: 'Déposez des fichiers ici ou collez une capture', removeFile: 'Retirer', pasteHint: '⌘V pour coller des images',
depsWait: 'deps', specLabel: 'SPEC DES CHANGEMENTS', opsLabel: 'OPÉRATIONS PRÉVUES',
pendingDoc: 'En attente de la sortie de Claude Code (via MCP, puis revue)',
emptyTasks: 'Aucune tâche — « + Nouvelle tâche » ou via MCP', empty: 'VIDE',