From 7708828c7259eb1c9b595a99af5fd48276e83ef0 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Wed, 19 Aug 2026 10:29:55 +0800 Subject: [PATCH] feat(web): redesign file and software job workspace --- CHANGELOG.md | 2 + tests/frontend_files.test.mjs | 35 +++++++ tests/test_static_vendor.py | 18 ++-- web/static/dev.html | 183 ++++++++++++++++++++------------- web/static/js/chat.js | 7 ++ web/static/js/files.js | 97 ++++++++++++++++- web/static/js/layout.js | 54 ++++++++-- web/static/js/software_jobs.js | 55 +++++----- web/static/js/state.js | 3 + 9 files changed, 329 insertions(+), 125 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6dd8f..68cf2c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 右侧工作区改为“文件 / 软件作业”双面板,标准页面和 embed 模式保持一致,折叠后仍可查看作业数量与完成提示;文件面板新增“当前对话 / 全部文件”切换、当前目录搜索、排序和修改时间展示。 + - 专业软件任务提交后由系统统一发送完成通知和产物,Agent 不再自行等待并重复报告;Origin PNG 改由清理异常文字基线后的矢量页渲染,三维图在指定毫米画布时也会同步缩放模板图层,避免横线及坐标标题、色标和文字裁切。 - 专业软件任务完成后可按用户意图仅报告产物,或自动读取图表和数据并给出分析;Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。 diff --git a/tests/frontend_files.test.mjs b/tests/frontend_files.test.mjs index cd8d96b..08db2d4 100644 --- a/tests/frontend_files.test.mjs +++ b/tests/frontend_files.test.mjs @@ -3,6 +3,9 @@ import fs from "node:fs"; import test from "node:test"; const filesJs = fs.readFileSync("web/static/js/files.js", "utf8"); +const layoutJs = fs.readFileSync("web/static/js/layout.js", "utf8"); +const jobsJs = fs.readFileSync("web/static/js/software_jobs.js", "utf8"); +const pageHtml = fs.readFileSync("web/static/dev.html", "utf8"); test("file menu exposes an explicit confirmed replacement action", () => { assert.match(filesJs, /label: "替换文件"/); @@ -17,3 +20,35 @@ test("ordinary uploads default to rename conflict policy", () => { assert.match(filesJs, /fd\.append\("conflict", opts\.conflict \|\| "rename"\)/); assert.match(filesJs, /同名文件已保留为/); }); + +test("file workspace exposes explicit task and all-files contexts", () => { + assert.match(pageHtml, /id="files-scope-task"[^>]*>当前对话]*>全部文件 { + assert.match(pageHtml, /id="file-search"/); + assert.match(pageHtml, /id="file-sort"/); + assert.match(pageHtml, /value="mtime-desc"/); + assert.match(pageHtml, /value="size-desc"/); + assert.match(filesJs, /visibleEntries/); + assert.match(filesJs, /fileMtime\(e\.mtime\)/); +}); + +test("software jobs share the embed-safe right workspace", () => { + assert.match(pageHtml, /data-right-panel-tab="files"/); + assert.match(pageHtml, /data-right-panel-tab="jobs"/); + assert.match(pageHtml, /id="right-jobs-panel"/); + assert.doesNotMatch(pageHtml, /id="software-job-backdrop"/); + assert.doesNotMatch(pageHtml, /id="software-job-panel"/); + assert.match(layoutJs, /setRightPanelTab/); + assert.match(jobsJs, /unreadTerminal/); +}); + +test("mobile navigation distinguishes list, files, and software jobs", () => { + assert.match(pageHtml, /id="mv-tab-left"[^>]*>\s*列表/); + assert.match(pageHtml, /id="mv-tab-right"[^>]*data-right-tab="files"/); + assert.match(pageHtml, /id="mv-tab-jobs"[^>]*data-right-tab="jobs"/); +}); diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index c97188e..f98f4ae 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -26,19 +26,19 @@ def _frontend_source() -> str: class StaticVendorTests(unittest.TestCase): - def test_dev_console_has_software_job_center(self) -> None: + def test_dev_console_has_embed_safe_software_job_workspace(self) -> None: html = DEV_HTML.read_text(encoding="utf-8") source = (JS_DIR / "software_jobs.js").read_text(encoding="utf-8") chat_source = (JS_DIR / "chat.js").read_text(encoding="utf-8") - self.assertIn('id="software-job-center"', html) - self.assertIn('id="software-job-toggle"', html) - self.assertIn("#software-job-center {", html) - self.assertIn("#software-job-toggle {", html) + self.assertIn('id="right-tab-jobs"', html) + self.assertIn('id="right-jobs-panel"', html) + self.assertIn('id="software-job-badge"', html) + self.assertIn(".right-workspace-panel {", html) self.assertLess( - html.index('id="software-job-center"'), + html.index('id="right-tab-jobs"'), html.index('id="storage-foot"'), ) - self.assertIn('id="software-job-panel"', html) + self.assertNotIn('id="software-job-panel"', html) self.assertIn("before_created_at", source) self.assertIn("before_job_id", source) self.assertIn("onListScroll", source) @@ -137,14 +137,14 @@ class StaticVendorTests(unittest.TestCase): html = DEV_HTML.read_text(encoding="utf-8") files_js = (JS_DIR / "files.js").read_text(encoding="utf-8") - self.assertIn('id="btn-toggle-hidden" class="small"', html) + self.assertRegex(html, r'id="btn-toggle-hidden" class="[^"]*\bsmall\b') self.assertNotIn( 'id="btn-toggle-hidden" class="small" title="显示点目录" ' 'aria-label="显示点目录" aria-pressed="false" hidden', html, ) self.assertNotIn("hiddenToggle.hidden", files_js) - self.assertIn('id="btn-file-add" class="small dd-toggle"', html) + self.assertRegex(html, r'id="btn-file-add" class="[^"]*\bsmall\b[^"]*\bdd-toggle\b') self.assertNotIn('id="btn-new-folder"', html) self.assertNotIn('id="btn-src-pick"', html) self.assertNotIn('id="btn-upload"', html) diff --git a/web/static/dev.html b/web/static/dev.html index 751aaeb..4f87f59 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -520,12 +520,15 @@ body.right-collapsed #app.ready { --right-grid-width: 40px; } body.right-collapsed #pane-right > * { display: none; } body.right-collapsed #pane-right > .pane-head:first-child { - display: flex; justify-content: center; align-items: center; + display: flex; flex-direction: column; justify-content: flex-start; align-items: center; padding: 6px 4px; border-bottom: none; background: transparent; - position: static; + position: static; height: 100%; } body.right-collapsed #pane-right > .pane-head:first-child > * { display: none; } - body.right-collapsed #pane-right > .pane-head:first-child > #pane-toggle-right { display: inline-block; } + body.right-collapsed #pane-right > .pane-head:first-child > .workspace-tab, + body.right-collapsed #pane-right > .pane-head:first-child > #pane-toggle-right { display: inline-flex; } + body.right-collapsed #pane-right > .pane-head:first-child > #pane-toggle-right { margin-top: auto; } + body.right-collapsed .workspace-tab span:not(.workspace-badge) { display: none; } header { grid-area: head; background: #fff; border-bottom: 1px solid var(--border); @@ -556,9 +559,14 @@ #task-scroll { flex: 1; min-height: 0; overflow: auto; } /* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */ #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; position: relative; } - /* flex 列:pane-head / crumbs / 上传状态固定,#file-list 独占滚动,存储条钉底 */ + /* 右侧工作区:页签头固定,文件 / 作业面板各自管理内部滚动 */ #pane-right { grid-area: right; border-right: none; display: flex; flex-direction: column; overflow: hidden; background: var(--panel); min-height: 0; } - #pane-right > .pane-head, #pane-right > #file-crumbs, #pane-right > .upload-status { flex-shrink: 0; } + #pane-right > .pane-head { flex-shrink: 0; } + .right-workspace-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + #right-jobs-panel { display: none; } + body.right-tab-jobs #right-files-panel { display: none; } + body.right-tab-jobs #right-jobs-panel { display: flex; } + body.right-tab-jobs .file-panel-control { display: none; } #file-list { flex: 1 1 auto; overflow: auto; min-height: 0; } .splitter { @@ -1069,42 +1077,30 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; } .proc-toast.fail { border-color: rgba(192,57,43,0.5); } - #software-job-center { display: block; flex-shrink: 0; font-size: 12px; } - #software-job-toggle { - width: 100%; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; - align-items: center; gap: 8px; padding: 9px 12px; text-align: left; - border: 0; border-top: 1px solid var(--border); background: #fff; cursor: pointer; + .right-workspace-head { gap: 4px; } + .mobile-workspace-title { display: none; font-size: 13px; font-weight: 600; } + .workspace-tab { + position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 5px; + min-width: 0; padding: 4px 7px; border: 0; border-radius: 6px; background: transparent; + color: var(--muted); cursor: pointer; font-size: 12px; } - #software-job-toggle:hover { background: #fafafa; } - .sj-entry-icon { - position: relative; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 8px; - color: var(--accent); background: var(--accent-soft); border: 1px solid rgba(192,57,43,.14); + .workspace-tab:hover { background: var(--hover); color: var(--text); } + .workspace-tab.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } + .workspace-tab svg { width: 16px; height: 16px; flex-shrink: 0; } + .workspace-badge { + min-width: 16px; height: 16px; padding: 0 4px; display: inline-flex; align-items: center; justify-content: center; + border-radius: 999px; background: var(--accent); color: #fff; font-size: 10px; font-style: normal; line-height: 1; } - .sj-entry-dot { position: absolute; right: -2px; bottom: -2px; width: 9px; height: 9px; border: 2px solid #fff; border-radius: 50%; background: #9ca3af; } + .sj-entry-dot { position: absolute; right: 2px; top: 1px; width: 8px; height: 8px; border: 2px solid #fafafa; border-radius: 50%; background: transparent; } .sj-entry-dot.active { background: #e28a2b; box-shadow: 0 0 0 2px rgba(226,138,43,.14); } .sj-entry-dot.succeeded { background: #35a164; } .sj-entry-dot.failed { background: var(--danger); } - .sj-entry-icon svg, .sj-panel-icon svg { width: 17px; height: 17px; } - .sj-entry-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; } - .sj-entry-title { font-weight: 600; color: var(--text); } - #software-job-count { color: var(--muted); font-size: 11px; font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .sj-entry-arrow { color: var(--muted); font-size: 18px; } - #software-job-backdrop { - position: fixed; inset: 0; z-index: 129; background: rgba(0,0,0,.18); - opacity: 0; visibility: hidden; transition: opacity .18s ease, visibility .18s ease; + .software-job-summary { + min-height: 39px; flex-shrink: 0; display: flex; align-items: center; gap: 8px; + padding: 7px 10px; border-bottom: 1px solid var(--border); background: #fafafa; font-size: 12px; } - #software-job-backdrop.open { opacity: 1; visibility: visible; } - #software-job-panel { - position: fixed; z-index: 130; top: 0; right: 0; bottom: 0; - width: min(480px, 94vw); display: flex; flex-direction: column; background: #fff; - box-shadow: -8px 0 28px rgba(0,0,0,.16); transform: translateX(105%); - visibility: hidden; transition: transform .2s ease, visibility .2s ease; - } - #software-job-panel.open { transform: translateX(0); visibility: visible; } - .sj-panel-head { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 13px 16px; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, #fff, #fffafa); } - .sj-panel-icon { width: 32px; height: 32px; display: grid; place-items: center; border-radius: 9px; color: #fff; background: linear-gradient(135deg, var(--accent), #8e2a20); box-shadow: 0 3px 8px rgba(192,57,43,.2); } - .sj-panel-head strong { font-size: 15px; } - .sj-panel-head .spacer { flex: 1; } + .software-job-summary strong { white-space: nowrap; } + #software-job-count { flex: 1; min-width: 0; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #software-job-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 10px 18px; } .sj-card { margin: 8px 0; padding: 11px 12px; border: 1px solid var(--border-soft); border-radius: 9px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.035); transition: border-color .15s ease, box-shadow .15s ease; } .sj-card:hover { border-color: var(--border); box-shadow: 0 3px 10px rgba(0,0,0,.055); } @@ -1262,6 +1258,21 @@ #chat-form.drag-over * { pointer-events: none; } /* ───── files ───── */ + .file-context { + min-height: 38px; display: flex; align-items: center; gap: 4px; padding: 6px 10px; + border-bottom: 1px solid var(--border); background: #fff; + } + .file-context button { + padding: 4px 7px; border: 1px solid transparent; border-radius: 6px; background: transparent; + color: var(--muted); font-size: 11px; cursor: pointer; white-space: nowrap; + } + .file-context button:hover:not(:disabled) { background: var(--hover); color: var(--text); } + .file-context button.active { border-color: var(--accent-soft); background: var(--accent-soft); color: var(--accent); } + .file-context button:disabled { opacity: .45; cursor: default; } + #files-proj { margin-left: auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .file-tools { display: flex; gap: 6px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); background: #fafafa; } + .file-tools input { flex: 1 1 auto; min-width: 0; padding: 5px 7px; font-size: 12px; } + .file-tools select { flex: 0 0 105px; min-width: 0; padding: 4px; font-size: 11px; } .crumbs { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; } .crumbs a { margin-right: 4px; } .file-row { @@ -1269,7 +1280,9 @@ align-items: center; gap: 8px; } .file-row:hover { background: var(--hover); } - .file-row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .file-row .file-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } + .file-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .file-row .mtime { color: var(--muted); font-size: 10px; font-family: var(--mono); } .file-row .size { font-size: 11px; color: var(--muted); font-family: var(--mono); } .ico-dir::before { content: "▸ "; color: var(--accent); } .ico-file::before { content: "· "; color: var(--muted); } @@ -1538,6 +1551,7 @@ /* ───── mobile tab nav (header) — 桌面隐藏,手机断点内显示 ───── */ .mobile-tabs { display: none; gap: 4px; } .mobile-tabs button { + position: relative; padding: 5px 10px; font-size: 12px; line-height: 1.2; background: transparent; border: 1px solid var(--border); border-radius: var(--r-md); color: var(--muted); cursor: pointer; @@ -1546,6 +1560,12 @@ .mobile-tabs button.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); } + .mobile-tabs .workspace-badge { position: absolute; top: -5px; right: -3px; } + .mobile-tabs button.has-unread::after { + content: ""; position: absolute; top: -4px; left: -3px; width: 7px; height: 7px; + border: 2px solid #fff; border-radius: 50%; background: #35a164; + } + .mobile-tabs button.has-unread-failed::after { background: var(--danger); } /* ───── responsive: tablet (641-1024px) ───── 断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */ @@ -1586,6 +1606,10 @@ body.mv-right #pane-right { display: flex; } /* 折叠按钮在手机不可见 */ #pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; } + .right-workspace-head .workspace-tab { display: none; } + .mobile-workspace-title.files { display: inline; } + body.right-tab-jobs .mobile-workspace-title.files { display: none; } + body.right-tab-jobs .mobile-workspace-title.jobs { display: inline; } /* header 紧凑化;品牌名较长 → 标题单行省略,不挤掉右侧按钮 */ header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; } @@ -1995,11 +2019,12 @@ title="管理后台(仅管理员)" style="display:none;">管理 - +
- + - + +
@@ -2135,33 +2160,53 @@
-
- 文件 - - - - - - -
-
-
加载中…
-
-
- + + + + + +
-
- - 存储 - - +
+
+ + + +
+
+ + +
+
+
加载中…
+
+
+ + 存储 + + +
+
+
松开以上传到当前目录
@@ -2265,16 +2310,6 @@
-
- - diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 842a3d4..9c8e703 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -589,6 +589,7 @@ function selectDraftFolder(name) { state.draftNewDirMode = false; state.draftWorkingDir = name; setDraftFolderOpen(false); + state.filesScope = "all"; state.filesPath = name; loadFiles(); syncDraftAction(); @@ -600,6 +601,7 @@ function selectDraftNewFolder(name) { if (!trimmed) return; state.draftNewDirMode = true; state.draftWorkingDir = trimmed; + state.filesScope = "all"; state.filesPath = ""; loadFiles(); setDraftFolderOpen(false); @@ -693,6 +695,7 @@ export async function showNewConversationDraft() { state.outline = []; state.draftNewDirMode = false; state.draftModelProfile = defaultDraftModelProfile(); + state.filesScope = "all"; state.filesPath = ""; document.querySelectorAll(".task-row").forEach(el => el.classList.remove("active")); syncChannelCardActive(null); @@ -799,6 +802,7 @@ export async function selectTask(tid) { // 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录), // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; + state.filesScope = wdName ? "task" : "all"; state.filesPath = wdName || ""; await loadFiles(); refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget @@ -1932,6 +1936,7 @@ export async function openSoftwareJobResults(taskId, outputDir) { await selectTask(taskId); const workingDir = state.taskMeta?.working_dir || ""; const base = workingDir.split("/").filter(Boolean).pop() || ""; + state.filesScope = base ? "task" : "all"; state.filesPath = [base, outputDir].filter(Boolean).join("/"); await loadFiles(); } @@ -2633,6 +2638,7 @@ $("chat-stream").addEventListener("click", (e) => { } const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]"); if (inlineImg) { + if (inlineImg.dataset.previewUrl) return; const rel = inlineImg.dataset.rel; if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1", inlineImg.dataset.artifactId || ""); return; @@ -2713,6 +2719,7 @@ async function createTaskFromDraft() { }); state.taskId = t.task_id; state.taskMeta = t; + state.filesScope = "task"; state.filesPath = workingDir; localStorage.setItem(LS_RECENT_WORKING_DIR, workingDir); $("chat-stream").innerHTML = ""; diff --git a/web/static/js/files.js b/web/static/js/files.js index 392672a..c5d4141 100644 --- a/web/static/js/files.js +++ b/web/static/js/files.js @@ -14,8 +14,21 @@ import { downloadFile } from "./media.js"; import { selectTask, loadTaskList } from "./chat.js"; import { loadFolderSuggestions } from "./newtask.js"; import { dialogConfirm, dialogPrompt, message } from "./dialog.js"; +import { setRightPanelTab } from "./layout.js"; // ───── files(user-rooted,不绑 task) ───── +let lastFilesData = null; + +function currentTaskFolder() { + const raw = state.taskMeta?.working_dir || ""; + return raw.split("/").filter(Boolean).pop() || ""; +} + +function resetFileSearch() { + state.filesQuery = ""; + $("file-search").value = ""; +} + $("btn-refresh-files").onclick = () => loadFiles(); $("btn-toggle-hidden").onclick = () => { state.showHiddenDirs = !state.showHiddenDirs; @@ -29,6 +42,29 @@ $("btn-file-add").onclick = (e) => { ]); }; $("upload-input").addEventListener("change", uploadSelected); +$("files-scope-task").onclick = () => { + const folder = currentTaskFolder(); + if (!folder) return; + state.filesScope = "task"; + state.filesPath = folder; + resetFileSearch(); + loadFiles(); +}; +$("files-scope-all").onclick = () => { + state.filesScope = "all"; + state.filesPath = ""; + resetFileSearch(); + loadFiles(); +}; +$("file-search").oninput = (event) => { + state.filesQuery = event.currentTarget.value; + if (lastFilesData) renderFiles(lastFilesData); +}; +$("file-sort").onchange = (event) => { + state.filesSort = event.currentTarget.value; + if (lastFilesData) renderFiles(lastFilesData); +}; +$("file-sort").value = state.filesSort; async function createFolder() { const rawName = await dialogPrompt({ @@ -187,6 +223,7 @@ function _hasFiles(ev) { $("pane-right").addEventListener("dragenter", (e) => { if (!_hasFiles(e)) return; e.preventDefault(); + setRightPanelTab("files"); _dragDepth++; $("file-droparea").classList.add("show"); }); @@ -225,6 +262,7 @@ export function scheduleFilesRefresh() { export async function loadFiles() { try { + if (lastFilesData && lastFilesData.current !== state.filesPath) resetFileSearch(); const params = new URLSearchParams(); if (state.filesPath) params.set("path", state.filesPath); if (state.showHiddenDirs && state.taskId) { @@ -233,21 +271,50 @@ export async function loadFiles() { } const qs = params.toString(); const data = await api("GET", "/v1/files" + (qs ? "?" + qs : "")); + lastFilesData = data; renderFiles(data); } catch (e) { if (e.status === 401) { logout(); return; } $("file-crumbs").innerHTML = `${escapeHtml(e.message)}`; $("file-list").innerHTML = ""; + lastFilesData = null; } } // 切换文件面板浏览路径 function navFiles(newPath) { state.filesPath = newPath || ""; + if (!state.filesPath) state.filesScope = "all"; + resetFileSearch(); loadFiles(); } +function compareFileEntries(a, b) { + if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; + const [field, direction] = (state.filesSort || "name-asc").split("-"); + let compared = 0; + if (field === "mtime") compared = new Date(a.mtime || 0) - new Date(b.mtime || 0); + else if (field === "size") compared = Number(a.size || 0) - Number(b.size || 0); + else compared = a.name.localeCompare(b.name, "zh-CN", { numeric: true, sensitivity: "base" }); + if (compared === 0 && field !== "name") { + compared = a.name.localeCompare(b.name, "zh-CN", { numeric: true, sensitivity: "base" }); + } + return direction === "desc" ? -compared : compared; +} + +function fileMtime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleString("zh-CN", { + year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", hour12: false, + }); +} + function renderFiles(data) { + $("file-search").value = state.filesQuery; + $("file-sort").value = state.filesSort; // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 const segs = (data.current || "").split("/").filter(Boolean); const projName = segs[0] || ""; @@ -255,9 +322,19 @@ function renderFiles(data) { const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName; $("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)"; $("files-proj").title = projName || data.root || ""; + const taskFolder = currentTaskFolder(); + const taskScope = state.filesScope === "task" && !!taskFolder; + const currentInTask = !!taskFolder && ( + data.current === taskFolder || data.current.startsWith(taskFolder + "/") + ); + $("files-scope-task").disabled = !taskFolder; + $("files-scope-task").classList.toggle("active", taskScope); + $("files-scope-all").classList.toggle("active", !taskScope); const hiddenToggle = $("btn-toggle-hidden"); + hiddenToggle.disabled = !currentInTask; hiddenToggle.setAttribute("aria-pressed", state.showHiddenDirs ? "true" : "false"); - hiddenToggle.title = state.showHiddenDirs ? "隐藏点目录" : "显示点目录"; + hiddenToggle.title = !currentInTask ? "技术目录仅在当前对话目录内可查看" + : (state.showHiddenDirs ? "隐藏技术目录" : "显示技术目录"); hiddenToggle.setAttribute("aria-label", hiddenToggle.title); // crumbs root 标"我的"(user_root),更直观;其余原样 const cr = data.crumbs.map((c, i) => { @@ -280,15 +357,25 @@ function renderFiles(data) { state.entriesByRel = {}; return; } + const query = state.filesQuery.trim().toLocaleLowerCase("zh-CN"); + const visibleEntries = data.entries + .filter((entry) => !query || entry.name.toLocaleLowerCase("zh-CN").includes(query)) + .sort(compareFileEntries); + if (!visibleEntries.length) { + $("file-list").innerHTML = `
未找到“${escapeHtml(state.filesQuery.trim())}”
`; + state.entriesByRel = {}; + return; + } state.entriesByRel = {}; - for (const e of data.entries) state.entriesByRel[e.rel] = e; - $("file-list").innerHTML = data.entries.map((e) => { + for (const e of visibleEntries) state.entriesByRel[e.rel] = e; + $("file-list").innerHTML = visibleEntries.map((e) => { const cls = e.is_dir ? "ico-dir" : "ico-file"; const fullTitle = e.rel || e.name; return `
- - ${escapeHtml(e.name)} + + ${escapeHtml(e.name)} + 修改 ${escapeHtml(fileMtime(e.mtime))} ${humanSize(e.size)} diff --git a/web/static/js/layout.js b/web/static/js/layout.js index 32a9161..25b84e4 100644 --- a/web/static/js/layout.js +++ b/web/static/js/layout.js @@ -1,6 +1,6 @@ // 三栏布局:pane 折叠(rail 模式 + localStorage)+ 拖拽 splitter + 手机单列视图。 -// 顶层即绑定 toggle/splitter/mobile-tab 事件并应用初始状态(import 时执行,DOM 已就绪)。 -// 只 setMobileView / mqPhone 对外(selectTask 在手机端选中任务时切到对话面板)。 +// 右栏是应用内工作区:展开时在“文件 / 软件作业”之间切换,折叠时两个 +// activity icon 仍可达。这一层不依赖外层 header,标准 / embed 共用。 import { $ } from "./dom.js"; import { LS_LEFT_COLLAPSED, LS_RIGHT_COLLAPSED, LS_LEFT_WIDTH, LS_RIGHT_WIDTH } from "./state.js"; @@ -33,7 +33,40 @@ function applyRightCollapsed(collapsed) { document.body.classList.toggle("right-collapsed", collapsed); const btn = $("pane-toggle-right"); btn.textContent = collapsed ? "‹" : "›"; - btn.title = collapsed ? "展开文件列表" : "折叠文件列表"; + btn.title = collapsed ? "展开工作区" : "折叠工作区"; +} + +let rightPanelTab = "files"; + +function syncMobileTabSelection(view) { + for (const button of document.querySelectorAll(".mobile-tabs button")) { + const sameView = button.dataset.mv === view; + const sameRightTab = view !== "mv-right" + || (button.dataset.rightTab || "files") === rightPanelTab; + button.classList.toggle("active", sameView && sameRightTab); + } +} + +export function setRightPanelTab(tab, { expand = true } = {}) { + rightPanelTab = tab === "jobs" ? "jobs" : "files"; + document.body.classList.toggle("right-tab-jobs", rightPanelTab === "jobs"); + $("right-files-panel").setAttribute("aria-hidden", String(rightPanelTab !== "files")); + $("right-jobs-panel").setAttribute("aria-hidden", String(rightPanelTab !== "jobs")); + for (const button of document.querySelectorAll("[data-right-panel-tab]")) { + const active = button.dataset.rightPanelTab === rightPanelTab; + button.classList.toggle("active", active); + button.setAttribute("aria-selected", String(active)); + } + if (expand && document.body.classList.contains("right-collapsed")) { + localStorage.setItem(LS_RIGHT_COLLAPSED, ""); + applyRightCollapsed(false); + } + const mobileView = document.body.classList.contains("mv-right") ? "mv-right" + : (document.body.classList.contains("mv-mid") ? "mv-mid" : "mv-left"); + syncMobileTabSelection(mobileView); + document.dispatchEvent(new CustomEvent("right-panel-changed", { + detail: { tab: rightPanelTab }, + })); } $("pane-toggle-left").onclick = () => { const next = !document.body.classList.contains("left-collapsed"); @@ -91,13 +124,14 @@ attachPaneSplitter("split-right", "right"); // body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class // 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) export const mqPhone = window.matchMedia("(max-width: 640px)"); -export function setMobileView(view) { +export function setMobileView(view, requestedRightTab = "") { // view ∈ "mv-left" | "mv-mid" | "mv-right" + if (view === "mv-right" && requestedRightTab) { + setRightPanelTab(requestedRightTab, { expand: false }); + } document.body.classList.remove("mv-left", "mv-mid", "mv-right"); document.body.classList.add(view); - for (const b of document.querySelectorAll(".mobile-tabs button")) { - b.classList.toggle("active", b.dataset.mv === view); - } + syncMobileTabSelection(view); } function applyMobileMode() { if (mqPhone.matches) { @@ -116,5 +150,9 @@ function applyMobileMode() { mqPhone.addEventListener("change", applyMobileMode); applyMobileMode(); for (const b of document.querySelectorAll(".mobile-tabs button")) { - b.onclick = () => setMobileView(b.dataset.mv); + b.onclick = () => setMobileView(b.dataset.mv, b.dataset.rightTab || ""); } +for (const button of document.querySelectorAll("[data-right-panel-tab]")) { + button.onclick = () => setRightPanelTab(button.dataset.rightPanelTab); +} +setRightPanelTab("files", { expand: false }); diff --git a/web/static/js/software_jobs.js b/web/static/js/software_jobs.js index 04c88d3..9d8f837 100644 --- a/web/static/js/software_jobs.js +++ b/web/static/js/software_jobs.js @@ -1,4 +1,4 @@ -// 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。 +// 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。 import { api } from "./api.js"; import { state } from "./state.js"; import { $ } from "./dom.js"; @@ -9,6 +9,7 @@ import { selectTask, } from "./chat.js"; import { dialogConfirm, message } from "./dialog.js"; +import { mqPhone, setMobileView, setRightPanelTab } from "./layout.js"; const ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]); const TERMINAL = new Set(["succeeded", "failed", "cancelled"]); @@ -19,9 +20,9 @@ let timer = null; let known = new Map(); let jobs = []; let nextCursor = null; -let expanded = false; let loadingMore = false; let initialized = false; +const unreadTerminal = new Set(); const statusLabel = { queued: "等待计算节点", offered: "正在分配节点", dispatched: "节点已接收", @@ -57,15 +58,16 @@ function statusIcon(status) { } export function initSoftwareJobs() { - if (initialized || !$("software-job-center")) return; + if (initialized || !$("software-job-list")) return; initialized = true; - $("software-job-toggle").onclick = openDrawer; - $("software-job-close").onclick = closeDrawer; - $("software-job-backdrop").onclick = closeDrawer; + $("software-job-refresh").onclick = refreshSoftwareJobs; $("software-job-list").addEventListener("scroll", onListScroll); document.addEventListener("software-job-submitted", refreshSoftwareJobs); - document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && expanded) closeDrawer(); + document.addEventListener("right-panel-changed", (event) => { + if (event.detail?.tab === "jobs") { + unreadTerminal.clear(); + render(); + } }); refreshSoftwareJobs(); } @@ -146,31 +148,26 @@ document.addEventListener("visibilitychange", () => { if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule(); }); -function openDrawer() { - expanded = true; - render(); -} - -function closeDrawer() { - expanded = false; - render(); -} - function render() { const active = jobs.filter((job) => ACTIVE.has(job.status)); const latest = jobs[0]; + const unreadJob = jobs.find((job) => unreadTerminal.has(job.job_id)); const entryDot = $("software-job-entry-dot"); entryDot.className = "sj-entry-dot"; - if (active.length) entryDot.classList.add("active"); - else if (latest?.status === "succeeded") entryDot.classList.add("succeeded"); - else if (latest?.status === "failed") entryDot.classList.add("failed"); + if (unreadJob?.status === "failed") entryDot.classList.add("failed"); + else if (unreadJob) entryDot.classList.add("succeeded"); + else if (active.length) entryDot.classList.add("active"); + for (const id of ["software-job-badge", "software-job-mobile-badge"]) { + const badge = $(id); + badge.hidden = active.length === 0; + badge.textContent = active.length ? String(active.length) : ""; + } + $("mv-tab-jobs").classList.toggle("has-unread", unreadTerminal.size > 0); + $("mv-tab-jobs").classList.toggle("has-unread-failed", unreadJob?.status === "failed"); $("software-job-count").textContent = active.length ? `${active.length} 个进行中 · ${statusLabel[active[0].status]}` - : (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无任务"); - $("software-job-panel").classList.toggle("open", expanded); - $("software-job-panel").setAttribute("aria-hidden", String(!expanded)); - $("software-job-backdrop").classList.toggle("open", expanded); - if (expanded) renderList(); + : (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无作业"); + renderList(); } function renderList() { @@ -313,7 +310,8 @@ async function handleAction(event, button) { const taskId = button.dataset.taskId; if (action === "results") { await openSoftwareJobResults(taskId, jobs.find((item) => item.job_id === jobId)?.output_dir || ""); - closeDrawer(); + setRightPanelTab("files"); + if (mqPhone.matches) setMobileView("mv-right", "files"); return; } if (action === "analyze") { @@ -323,7 +321,6 @@ async function handleAction(event, button) { if (taskId) await selectTask(taskId); await refreshCurrentTaskAfterSoftwareJob(taskId); message("已排队分析结果", "success"); - closeDrawer(); refreshSoftwareJobs(); } catch (error) { button.disabled = false; @@ -332,13 +329,13 @@ async function handleAction(event, button) { return; } if (taskId) await selectTask(taskId); - closeDrawer(); } function notifyTerminal(job) { const ok = job.status === "succeeded"; const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败"); const summary = job.request_summary || {}; + if (!document.body.classList.contains("right-tab-jobs")) unreadTerminal.add(job.job_id); message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000); void refreshCurrentTaskAfterSoftwareJob(job.task_id); } diff --git a/web/static/js/state.js b/web/static/js/state.js index d7ff9b5..3dae425 100644 --- a/web/static/js/state.js +++ b/web/static/js/state.js @@ -48,6 +48,9 @@ export const state = { draftNewDirMode: false, draftModelProfile: "", filesPath: "", + filesScope: "all", // all=用户根目录视图;task=跟随当前对话工作目录 + filesQuery: "", // 仅过滤当前已浏览目录 + filesSort: "name-asc", showHiddenDirs: false, // 仅当前 task 工作目录子树生效;user_root 永不展示 // 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示 concurrentWarnings: [],