From 00e6bc72e7bc9249aeed953c5f56eeb6ca8608e9 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 30 Jun 2026 01:49:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(console):=20=E9=99=84=E4=BB=B6=E5=85=A8?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E6=8E=A5=E7=BA=BF=EF=BC=88=E9=A2=84=E8=A7=88?= =?UTF-8?q?/=E4=B8=8B=E8=BD=BD/=E5=88=A0=E9=99=A4=20+=20=E5=B7=B2=E5=BB=BA?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E4=BA=A4=E4=BA=92=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/src/api.js: 新增 attachmentUrl(inline/下载 URL 构造)与 deleteAttachment 封装 - app/src/app.jsx: taskOps 增 delAttachment(删除→toast→局部刷新) - design/ui_kits/console/TaskTree.jsx: TaskDetail 附件区从纯文本升级为画廊 (图片缩略图点击新标签预览 + 下载按钮 + 确认删除),URL 内联零依赖、删除走 taskOps - design/ui_kits/console/i18n.js: 新增 attSection/attDownload/attDeleteConfirm/ attDeleted/attDeleteFailed(zh/en/es/ja/fr 五语齐全) tsk_dbtI_ffTJll9 Co-Authored-By: Claude Opus 4.8 --- app/src/api.js | 7 +++++ app/src/app.jsx | 5 ++++ design/ui_kits/console/TaskTree.jsx | 44 ++++++++++++++++++++++++++++- design/ui_kits/console/i18n.js | 5 ++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/src/api.js b/app/src/api.js index 7314333..e2cda5b 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -52,6 +52,13 @@ export const api = { } return res.json(); }, + // 附件预览/下载 URL:默认 inline(图片可 预览),download=true 触发下载。 + // name = basename(att.path)(磁盘安全名,非展示名)。纯字符串构造,GET 走 / 由浏览器处理。 + attachmentUrl: (taskId, name, opts = {}) => + `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${opts.download ? '?download=1' : ''}`, + // 删除单个附件(按磁盘文件名)。返回 { attachments } 最新列表。 + deleteAttachment: (taskId, name) => + http('DELETE', `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}`), }; // WS 单向订阅:连上后服务端推送 events 表记录。断线自动重连(指数退避,封顶 10s)。 diff --git a/app/src/app.jsx b/app/src/app.jsx index c9adb73..1f1950e 100644 --- a/app/src/app.jsx +++ b/app/src/app.jsx @@ -387,6 +387,11 @@ export function App() { try { await api.deleteTask(taskId); toast('warn', '已删除'); loadProject(currentId); loadGlobal(); } catch (e) { toast('warn', '删除失败:' + e.message); } }, + // 删除已建任务的单个附件(name = 磁盘文件名 basename(att.path)),成功后局部刷新 + delAttachment: async (taskId, name) => { + try { await api.deleteAttachment(taskId, name); toast('warn', t.attDeleted); loadProject(currentId); } + catch (e) { toast('warn', t.attDeleteFailed + e.message); } + }, }; const [showNewProject, setShowNewProject] = React.useState(false); const createProject = async (body) => { diff --git a/design/ui_kits/console/TaskTree.jsx b/design/ui_kits/console/TaskTree.jsx index 1a8da5a..3b83c27 100644 --- a/design/ui_kits/console/TaskTree.jsx +++ b/design/ui_kits/console/TaskTree.jsx @@ -253,6 +253,7 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO } function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) { + ensureNewTaskCss(); const { Button, Input, Select } = window.MaestroDesignSystem_a6a290; const label = task.doc ? t.specLabel : task.ops ? t.opsLabel : null; const text = task.doc || task.ops; @@ -283,8 +284,37 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) { {ops.requeue && canRequeue ? : null} {ops.cancel && canCancel ? : null} {ops.del ? : null} - {attachments.length ? 📎 {attachments.map((a) => a.name).join(' · ')} : null} + {/* 附件画廊:图片缩略图(点击新标签预览)+ 下载 + 删除(确认后调用 taskOps.delAttachment) */} + {attachments.length ? ( +
e.stopPropagation()}> +
{t.attSection} · {attachments.length}
+
+ {attachments.map((a) => { + const dn = attDiskName(a); + const isImg = (a.type || '').startsWith('image/'); + const url = attUrl(task.id, dn); + return ( +
+ + + {isImg ? {a.name} : {(a.name.includes('.') ? a.name.split('.').pop() : (a.type || 'file').split('/').pop() || 'file').toUpperCase()}} + + + {a.name} + + ↓ {t.attDownload} + {ops.delAttachment ? ( + + ) : null} + +
+ ); + })} +
+
+ ) : null} {/* 编辑表单 */} {editing ? (
e.stopPropagation()}> @@ -373,10 +403,22 @@ function ensureNewTaskCss() { .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); } +.m-att-thumb-link { display: block; width: 92px; height: 64px; } +.m-att-act { display: flex; gap: 6px; align-items: center; } +.m-att-btn { font-family: var(--mono); font-size: 10px; line-height: 1; padding: 3px 6px; border: 1px solid var(--line); border-radius: var(--radius-sm, 4px); background: var(--panel); color: var(--muted); cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; transition: color .12s, border-color .12s, background .12s; } +.m-att-btn:hover { color: var(--green); border-color: var(--green-dim); background: var(--panel-2); } +.m-att-btn.is-del:hover { color: var(--red); border-color: var(--red-dim); } `; document.head.appendChild(s); } +// 已建任务附件:磁盘文件名(后端 :name 标识符 = basename(att.path),非展示名 att.name) +function attDiskName(a) { return (a.path || '').split('/').pop() || a.name || ''; } +// 附件预览/下载 URL(UI kit 内联,零外部依赖;与 app/src/api.js attachmentUrl 同构) +function attUrl(taskId, name, download) { + return `/api/tasks/${taskId}/attachments/${encodeURIComponent(name)}${download ? '?download=1' : ''}`; +} + // 三来源(选择/粘贴/拖拽)统一去重键:name+size+lastModified function ntFileKey(f) { return (f.name || '') + '' + f.size + '' + f.lastModified; } // 由 MIME 推断扩展名(剪贴板图片常无文件名) diff --git a/design/ui_kits/console/i18n.js b/design/ui_kits/console/i18n.js index 7d2d8e1..9397f9e 100644 --- a/design/ui_kits/console/i18n.js +++ b/design/ui_kits/console/i18n.js @@ -41,6 +41,7 @@ window.MAESTRO_I18N = { complexity: '复杂度', cplxAuto: '智能', parentTask: '父任务', topLevel: '(顶层)', priority: '优先级', p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: '创建任务', cancel: '取消', attachLabel: '附件 · 图片/文件(可选)', dropHint: '拖拽文件到此处,或粘贴截图', removeFile: '移除', pasteHint: '支持 ⌘V 粘贴图片', + attSection: '附件', attDownload: '下载', attDeleteConfirm: '删除附件「{name}」?此操作不可恢复。', attDeleted: '附件已删除', attDeleteFailed: '删除附件失败:', depsWait: '待依赖', specLabel: '改动方案(SPEC)', opsLabel: '将执行的操作(OPERATIONS)', pendingDoc: '待 Claude Code 产出(经 MCP 写入并提交评审)', emptyTasks: '该项目暂无任务 —— 点「+ 新建任务」或经 MCP 创建', empty: '空', @@ -92,6 +93,7 @@ window.MAESTRO_I18N = { 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', + attSection: 'Attachments', attDownload: 'Download', attDeleteConfirm: 'Delete attachment "{name}"? This cannot be undone.', attDeleted: 'Attachment deleted', attDeleteFailed: 'Failed to delete attachment: ', 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', @@ -143,6 +145,7 @@ window.MAESTRO_I18N = { 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', + attSection: 'Adjuntos', attDownload: 'Descargar', attDeleteConfirm: '¿Eliminar el adjunto «{name}»? Esta acción no se puede deshacer.', attDeleted: 'Adjunto eliminado', attDeleteFailed: 'Error al eliminar el adjunto: ', 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', @@ -194,6 +197,7 @@ window.MAESTRO_I18N = { complexity: '複雑度', cplxAuto: '智能', parentTask: '親タスク', topLevel: '(トップ)', priority: '優先度', p0: 'P0 · 高', p1: 'P1 · 中', p2: 'P2 · 低', create: 'タスク作成', cancel: 'キャンセル', attachLabel: '添付 · 画像/ファイル(任意)', dropHint: 'ここにファイルをドロップ、または貼り付け', removeFile: '削除', pasteHint: '⌘Vで画像を貼り付け', + attSection: '添付', attDownload: 'ダウンロード', attDeleteConfirm: '添付「{name}」を削除しますか?この操作は元に戻せません。', attDeleted: '添付を削除しました', attDeleteFailed: '添付の削除に失敗:', depsWait: '依存待ち', specLabel: 'SPEC · 変更案', opsLabel: 'OPERATIONS · 操作', pendingDoc: 'Claude Code の出力待ち(MCP 経由で書き込み後レビューへ)', emptyTasks: 'タスクなし — 「+ 新規タスク」または MCP で作成', empty: '空', @@ -245,6 +249,7 @@ window.MAESTRO_I18N = { 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', + attSection: 'Pièces jointes', attDownload: 'Télécharger', attDeleteConfirm: 'Supprimer la pièce jointe « {name} » ? Action irréversible.', attDeleted: 'Pièce jointe supprimée', attDeleteFailed: 'Échec de la suppression : ', 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',