feat(web): redesign file and software job workspace

This commit is contained in:
caoqianming 2026-08-19 10:29:55 +08:00
parent 487d139d39
commit 7708828c72
9 changed files with 329 additions and 125 deletions

View File

@ -8,6 +8,8 @@
## Unreleased ## Unreleased
- 右侧工作区改为“文件 / 软件作业”双面板,标准页面和 embed 模式保持一致,折叠后仍可查看作业数量与完成提示;文件面板新增“当前对话 / 全部文件”切换、当前目录搜索、排序和修改时间展示。
- 专业软件任务提交后由系统统一发送完成通知和产物Agent 不再自行等待并重复报告Origin PNG 改由清理异常文字基线后的矢量页渲染,三维图在指定毫米画布时也会同步缩放模板图层,避免横线及坐标标题、色标和文字裁切。 - 专业软件任务提交后由系统统一发送完成通知和产物Agent 不再自行等待并重复报告Origin PNG 改由清理异常文字基线后的矢量页渲染,三维图在指定毫米画布时也会同步缩放模板图层,避免横线及坐标标题、色标和文字裁切。
- 专业软件任务完成后可按用户意图仅报告产物或自动读取图表和数据并给出分析Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。 - 专业软件任务完成后可按用户意图仅报告产物或自动读取图表和数据并给出分析Job 中心重新整理了状态、输入输出、耗时和执行详情,支持查看结果、复制 Job ID、深入分析及重新分析。

View File

@ -3,6 +3,9 @@ import fs from "node:fs";
import test from "node:test"; import test from "node:test";
const filesJs = fs.readFileSync("web/static/js/files.js", "utf8"); 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", () => { test("file menu exposes an explicit confirmed replacement action", () => {
assert.match(filesJs, /label: "替换文件"/); 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, /fd\.append\("conflict", opts\.conflict \|\| "rename"\)/);
assert.match(filesJs, /同名文件已保留为/); assert.match(filesJs, /同名文件已保留为/);
}); });
test("file workspace exposes explicit task and all-files contexts", () => {
assert.match(pageHtml, /id="files-scope-task"[^>]*>当前对话</);
assert.match(pageHtml, /id="files-scope-all"[^>]*>全部文件</);
assert.match(filesJs, /state\.filesScope = "task"/);
assert.match(filesJs, /state\.filesScope = "all"/);
});
test("current directory supports search, sort, and modification time", () => {
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"/);
});

View File

@ -26,19 +26,19 @@ def _frontend_source() -> str:
class StaticVendorTests(unittest.TestCase): 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") html = DEV_HTML.read_text(encoding="utf-8")
source = (JS_DIR / "software_jobs.js").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") chat_source = (JS_DIR / "chat.js").read_text(encoding="utf-8")
self.assertIn('id="software-job-center"', html) self.assertIn('id="right-tab-jobs"', html)
self.assertIn('id="software-job-toggle"', html) self.assertIn('id="right-jobs-panel"', html)
self.assertIn("#software-job-center {", html) self.assertIn('id="software-job-badge"', html)
self.assertIn("#software-job-toggle {", html) self.assertIn(".right-workspace-panel {", html)
self.assertLess( self.assertLess(
html.index('id="software-job-center"'), html.index('id="right-tab-jobs"'),
html.index('id="storage-foot"'), 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_created_at", source)
self.assertIn("before_job_id", source) self.assertIn("before_job_id", source)
self.assertIn("onListScroll", source) self.assertIn("onListScroll", source)
@ -137,14 +137,14 @@ class StaticVendorTests(unittest.TestCase):
html = DEV_HTML.read_text(encoding="utf-8") html = DEV_HTML.read_text(encoding="utf-8")
files_js = (JS_DIR / "files.js").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( self.assertNotIn(
'id="btn-toggle-hidden" class="small" title="显示点目录" ' 'id="btn-toggle-hidden" class="small" title="显示点目录" '
'aria-label="显示点目录" aria-pressed="false" hidden', 'aria-label="显示点目录" aria-pressed="false" hidden',
html, html,
) )
self.assertNotIn("hiddenToggle.hidden", files_js) 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-new-folder"', html)
self.assertNotIn('id="btn-src-pick"', html) self.assertNotIn('id="btn-src-pick"', html)
self.assertNotIn('id="btn-upload"', html) self.assertNotIn('id="btn-upload"', html)

View File

@ -520,12 +520,15 @@
body.right-collapsed #app.ready { --right-grid-width: 40px; } body.right-collapsed #app.ready { --right-grid-width: 40px; }
body.right-collapsed #pane-right > * { display: none; } body.right-collapsed #pane-right > * { display: none; }
body.right-collapsed #pane-right > .pane-head:first-child { 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; 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 > * { 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 { header {
grid-area: head; background: #fff; border-bottom: 1px solid var(--border); grid-area: head; background: #fff; border-bottom: 1px solid var(--border);
@ -556,9 +559,14 @@
#task-scroll { flex: 1; min-height: 0; overflow: auto; } #task-scroll { flex: 1; min-height: 0; overflow: auto; }
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: 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; } #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 { 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; } #file-list { flex: 1 1 auto; overflow: auto; min-height: 0; }
.splitter { .splitter {
@ -1069,42 +1077,30 @@
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px;
} }
.proc-toast.fail { border-color: rgba(192,57,43,0.5); } .proc-toast.fail { border-color: rgba(192,57,43,0.5); }
#software-job-center { display: block; flex-shrink: 0; font-size: 12px; } .right-workspace-head { gap: 4px; }
#software-job-toggle { .mobile-workspace-title { display: none; font-size: 13px; font-weight: 600; }
width: 100%; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; .workspace-tab {
align-items: center; gap: 8px; padding: 9px 12px; text-align: left; position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
border: 0; border-top: 1px solid var(--border); background: #fff; cursor: pointer; 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; } .workspace-tab:hover { background: var(--hover); color: var(--text); }
.sj-entry-icon { .workspace-tab.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
position: relative; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 8px; .workspace-tab svg { width: 16px; height: 16px; flex-shrink: 0; }
color: var(--accent); background: var(--accent-soft); border: 1px solid rgba(192,57,43,.14); .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.active { background: #e28a2b; box-shadow: 0 0 0 2px rgba(226,138,43,.14); }
.sj-entry-dot.succeeded { background: #35a164; } .sj-entry-dot.succeeded { background: #35a164; }
.sj-entry-dot.failed { background: var(--danger); } .sj-entry-dot.failed { background: var(--danger); }
.sj-entry-icon svg, .sj-panel-icon svg { width: 17px; height: 17px; } .software-job-summary {
.sj-entry-copy { min-width: 0; display: flex; flex-direction: column; gap: 2px; } min-height: 39px; flex-shrink: 0; display: flex; align-items: center; gap: 8px;
.sj-entry-title { font-weight: 600; color: var(--text); } padding: 7px 10px; border-bottom: 1px solid var(--border); background: #fafafa; font-size: 12px;
#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-backdrop.open { opacity: 1; visibility: visible; } .software-job-summary strong { white-space: nowrap; }
#software-job-panel { #software-job-count { flex: 1; min-width: 0; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
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-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 10px 18px; } #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 { 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); } .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; } #chat-form.drag-over * { pointer-events: none; }
/* ───── files ───── */ /* ───── 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 { padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 12px; background: #fafafa; }
.crumbs a { margin-right: 4px; } .crumbs a { margin-right: 4px; }
.file-row { .file-row {
@ -1269,7 +1280,9 @@
align-items: center; gap: 8px; align-items: center; gap: 8px;
} }
.file-row:hover { background: var(--hover); } .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); } .file-row .size { font-size: 11px; color: var(--muted); font-family: var(--mono); }
.ico-dir::before { content: "▸ "; color: var(--accent); } .ico-dir::before { content: "▸ "; color: var(--accent); }
.ico-file::before { content: "· "; color: var(--muted); } .ico-file::before { content: "· "; color: var(--muted); }
@ -1538,6 +1551,7 @@
/* ───── mobile tab nav (header) — 桌面隐藏,手机断点内显示 ───── */ /* ───── mobile tab nav (header) — 桌面隐藏,手机断点内显示 ───── */
.mobile-tabs { display: none; gap: 4px; } .mobile-tabs { display: none; gap: 4px; }
.mobile-tabs button { .mobile-tabs button {
position: relative;
padding: 5px 10px; font-size: 12px; line-height: 1.2; padding: 5px 10px; font-size: 12px; line-height: 1.2;
background: transparent; border: 1px solid var(--border); background: transparent; border: 1px solid var(--border);
border-radius: var(--r-md); color: var(--muted); cursor: pointer; border-radius: var(--r-md); color: var(--muted); cursor: pointer;
@ -1546,6 +1560,12 @@
.mobile-tabs button.active { .mobile-tabs button.active {
background: var(--accent-soft); border-color: var(--accent); color: var(--accent); 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) ───── /* ───── responsive: tablet (641-1024px) ─────
断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */ 断点内强制 rail(纯 CSS,不写 localStorage;回桌面用户偏好仍生效) */
@ -1586,6 +1606,10 @@
body.mv-right #pane-right { display: flex; } body.mv-right #pane-right { display: flex; }
/* 折叠按钮在手机不可见 */ /* 折叠按钮在手机不可见 */
#pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; } #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 紧凑化;品牌名较长 → 标题单行省略,不挤掉右侧按钮 */
header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; } header { padding: 6px 10px; gap: 6px; flex-wrap: wrap; }
@ -1995,11 +2019,12 @@
title="管理后台(仅管理员)" style="display:none;">管理</a> title="管理后台(仅管理员)" style="display:none;">管理</a>
<button id="hd-chpw" title="修改登录密码">改密码</button> <button id="hd-chpw" title="修改登录密码">改密码</button>
<button id="hd-logout">退出登录</button> <button id="hd-logout">退出登录</button>
<!-- 手机 tab(桌面 display:none):任务 / 对话 / 文件 --> <!-- 手机 tab(桌面 display:none):对话列表 / 对话 / 文件 / 软件作业 -->
<div class="mobile-tabs" role="tablist"> <div class="mobile-tabs" role="tablist">
<button id="mv-tab-left" data-mv="mv-left" class="active">任务</button> <button id="mv-tab-left" data-mv="mv-left" class="active">列表</button>
<button id="mv-tab-mid" data-mv="mv-mid">对话</button> <button id="mv-tab-mid" data-mv="mv-mid">对话</button>
<button id="mv-tab-right" data-mv="mv-right">文件</button> <button id="mv-tab-right" data-mv="mv-right" data-right-tab="files">文件</button>
<button id="mv-tab-jobs" data-mv="mv-right" data-right-tab="jobs">作业<span id="software-job-mobile-badge" class="workspace-badge" hidden></span></button>
</div> </div>
</header> </header>
@ -2135,33 +2160,53 @@
<!-- right --> <!-- right -->
<div id="pane-right"> <div id="pane-right">
<div class="pane-head"> <div class="pane-head right-workspace-head" role="tablist" aria-label="工作区">
<span class="label">文件</span> <span class="mobile-workspace-title files">文件</span>
<span id="files-proj" class="muted small" style="margin-left:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto;" title=""></span> <span class="mobile-workspace-title jobs">软件作业</span>
<span class="spacer"></span> <button id="right-tab-files" class="workspace-tab active" type="button" data-right-panel-tab="files" role="tab" aria-selected="true" title="文件">
<button id="btn-toggle-hidden" class="small" title="显示点目录" aria-label="显示点目录" aria-pressed="false">.*</button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H10l2 2h6.5A2.5 2.5 0 0 1 21 8.5v9A2.5 2.5 0 0 1 18.5 20h-13A2.5 2.5 0 0 1 3 17.5z"></path></svg>
<button id="btn-file-add" class="small dd-toggle" title="添加文件或文件夹" aria-label="添加文件或文件夹"> 添加</button> <span>文件</span>
<button id="btn-refresh-files" class="small"></button>
<button id="pane-toggle-right" class="small" title="折叠文件列表"></button>
</div>
<div id="file-upload-status" class="upload-status"></div>
<div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div>
<div id="software-job-center" aria-live="polite">
<button id="software-job-toggle" type="button" title="查看专业软件任务">
<span class="sj-entry-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M8 9h8M8 13h5"></path><path d="M9 5V3m6 2V3"></path></svg><i id="software-job-entry-dot" class="sj-entry-dot"></i></span>
<span class="sj-entry-copy">
<span class="sj-entry-title">专业软件任务</span>
<span id="software-job-count">暂无任务</span>
</span>
<span class="sj-entry-arrow" aria-hidden="true"></span>
</button> </button>
<button id="right-tab-jobs" class="workspace-tab" type="button" data-right-panel-tab="jobs" role="tab" aria-selected="false" title="软件作业">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M8 9h8M8 13h5"></path><path d="M9 5V3m6 2V3"></path></svg>
<span>作业</span><i id="software-job-entry-dot" class="sj-entry-dot"></i><span id="software-job-badge" class="workspace-badge" hidden></span>
</button>
<span class="spacer"></span>
<button id="btn-toggle-hidden" class="small file-panel-control" title="显示技术目录" aria-label="显示技术目录" aria-pressed="false">.*</button>
<button id="btn-file-add" class="small dd-toggle file-panel-control" title="添加文件或文件夹" aria-label="添加文件或文件夹"> 添加</button>
<button id="btn-refresh-files" class="small file-panel-control" title="刷新文件"></button>
<button id="pane-toggle-right" class="small" title="折叠工作区"></button>
</div> </div>
<div id="storage-foot" class="storage-foot" title=""> <div id="right-files-panel" class="right-workspace-panel" role="tabpanel" aria-labelledby="right-tab-files">
<span id="app-version" title="版本号"></span> <div id="file-context" class="file-context">
<span class="lbl">存储</span> <button id="files-scope-task" type="button" class="active">当前对话</button>
<span class="bar"><i id="storage-foot-bar"></i></span> <button id="files-scope-all" type="button">全部文件</button>
<span class="txt" id="storage-foot-txt"></span> <span id="files-proj" class="muted small" title=""></span>
</div>
<div class="file-tools">
<input id="file-search" type="search" placeholder="搜索当前目录" aria-label="搜索当前目录" />
<select id="file-sort" aria-label="文件排序" title="文件排序">
<option value="name-asc">名称 A→Z</option>
<option value="name-desc">名称 Z→A</option>
<option value="mtime-desc">最近修改</option>
<option value="mtime-asc">最早修改</option>
<option value="size-desc">大小 大→小</option>
<option value="size-asc">大小 小→大</option>
</select>
</div>
<div id="file-upload-status" class="upload-status"></div>
<div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div>
<div id="storage-foot" class="storage-foot" title="">
<span id="app-version" title="版本号"></span>
<span class="lbl">存储</span>
<span class="bar"><i id="storage-foot-bar"></i></span>
<span class="txt" id="storage-foot-txt"></span>
</div>
</div>
<div id="right-jobs-panel" class="right-workspace-panel" role="tabpanel" aria-labelledby="right-tab-jobs" aria-hidden="true">
<div class="software-job-summary"><strong>软件作业</strong><span id="software-job-count">暂无作业</span><button id="software-job-refresh" class="small" type="button" title="刷新作业"></button></div>
<div id="software-job-list" aria-live="polite"></div>
</div> </div>
<div id="file-droparea">松开以上传到当前目录</div> <div id="file-droparea">松开以上传到当前目录</div>
<input type="file" id="upload-input" multiple style="display:none;" /> <input type="file" id="upload-input" multiple style="display:none;" />
@ -2265,16 +2310,6 @@
</div> </div>
</div> </div>
<div id="software-job-backdrop"></div>
<aside id="software-job-panel" aria-hidden="true" aria-label="专业软件任务列表">
<div class="sj-panel-head">
<span class="sj-panel-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="3"></rect><path d="M8 9h8M8 13h5"></path><path d="M9 5V3m6 2V3"></path></svg></span>
<strong>专业软件任务</strong><span class="spacer"></span>
<button id="software-job-close" class="small" type="button" title="关闭">×</button>
</div>
<div id="software-job-list"></div>
</aside>
<script type="module" src="js/main.js"></script> <script type="module" src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -589,6 +589,7 @@ function selectDraftFolder(name) {
state.draftNewDirMode = false; state.draftNewDirMode = false;
state.draftWorkingDir = name; state.draftWorkingDir = name;
setDraftFolderOpen(false); setDraftFolderOpen(false);
state.filesScope = "all";
state.filesPath = name; state.filesPath = name;
loadFiles(); loadFiles();
syncDraftAction(); syncDraftAction();
@ -600,6 +601,7 @@ function selectDraftNewFolder(name) {
if (!trimmed) return; if (!trimmed) return;
state.draftNewDirMode = true; state.draftNewDirMode = true;
state.draftWorkingDir = trimmed; state.draftWorkingDir = trimmed;
state.filesScope = "all";
state.filesPath = ""; state.filesPath = "";
loadFiles(); loadFiles();
setDraftFolderOpen(false); setDraftFolderOpen(false);
@ -693,6 +695,7 @@ export async function showNewConversationDraft() {
state.outline = []; state.outline = [];
state.draftNewDirMode = false; state.draftNewDirMode = false;
state.draftModelProfile = defaultDraftModelProfile(); state.draftModelProfile = defaultDraftModelProfile();
state.filesScope = "all";
state.filesPath = ""; state.filesPath = "";
document.querySelectorAll(".task-row").forEach(el => el.classList.remove("active")); document.querySelectorAll(".task-row").forEach(el => el.classList.remove("active"));
syncChannelCardActive(null); syncChannelCardActive(null);
@ -799,6 +802,7 @@ export async function selectTask(tid) {
// 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录), // 文件面板自动跳到该 task 的 working_dir(user_root 下一级子目录),
// 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录 // 不强绑定 — 用户可点 crumb 回上层看 user_root 其他目录
const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : ""; const wdName = meta.working_dir ? meta.working_dir.split("/").filter(Boolean).pop() : "";
state.filesScope = wdName ? "task" : "all";
state.filesPath = wdName || ""; state.filesPath = wdName || "";
await loadFiles(); await loadFiles();
refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget refreshConcurrentWarnings(); // 同 wd 其他 task 活跃软警告 — 后台 fire-and-forget
@ -1932,6 +1936,7 @@ export async function openSoftwareJobResults(taskId, outputDir) {
await selectTask(taskId); await selectTask(taskId);
const workingDir = state.taskMeta?.working_dir || ""; const workingDir = state.taskMeta?.working_dir || "";
const base = workingDir.split("/").filter(Boolean).pop() || ""; const base = workingDir.split("/").filter(Boolean).pop() || "";
state.filesScope = base ? "task" : "all";
state.filesPath = [base, outputDir].filter(Boolean).join("/"); state.filesPath = [base, outputDir].filter(Boolean).join("/");
await loadFiles(); await loadFiles();
} }
@ -2633,6 +2638,7 @@ $("chat-stream").addEventListener("click", (e) => {
} }
const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]"); const inlineImg = e.target.closest && e.target.closest(".art-media-image[data-rel]");
if (inlineImg) { if (inlineImg) {
if (inlineImg.dataset.previewUrl) return;
const rel = inlineImg.dataset.rel; const rel = inlineImg.dataset.rel;
if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1", inlineImg.dataset.artifactId || ""); if (rel) openFilePreview(rel, inlineImg.dataset.taskId || "", inlineImg.dataset.legacyPath === "1", inlineImg.dataset.artifactId || "");
return; return;
@ -2713,6 +2719,7 @@ async function createTaskFromDraft() {
}); });
state.taskId = t.task_id; state.taskId = t.task_id;
state.taskMeta = t; state.taskMeta = t;
state.filesScope = "task";
state.filesPath = workingDir; state.filesPath = workingDir;
localStorage.setItem(LS_RECENT_WORKING_DIR, workingDir); localStorage.setItem(LS_RECENT_WORKING_DIR, workingDir);
$("chat-stream").innerHTML = ""; $("chat-stream").innerHTML = "";

View File

@ -14,8 +14,21 @@ import { downloadFile } from "./media.js";
import { selectTask, loadTaskList } from "./chat.js"; import { selectTask, loadTaskList } from "./chat.js";
import { loadFolderSuggestions } from "./newtask.js"; import { loadFolderSuggestions } from "./newtask.js";
import { dialogConfirm, dialogPrompt, message } from "./dialog.js"; import { dialogConfirm, dialogPrompt, message } from "./dialog.js";
import { setRightPanelTab } from "./layout.js";
// ───── files(user-rooted,不绑 task) ───── // ───── 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-refresh-files").onclick = () => loadFiles();
$("btn-toggle-hidden").onclick = () => { $("btn-toggle-hidden").onclick = () => {
state.showHiddenDirs = !state.showHiddenDirs; state.showHiddenDirs = !state.showHiddenDirs;
@ -29,6 +42,29 @@ $("btn-file-add").onclick = (e) => {
]); ]);
}; };
$("upload-input").addEventListener("change", uploadSelected); $("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() { async function createFolder() {
const rawName = await dialogPrompt({ const rawName = await dialogPrompt({
@ -187,6 +223,7 @@ function _hasFiles(ev) {
$("pane-right").addEventListener("dragenter", (e) => { $("pane-right").addEventListener("dragenter", (e) => {
if (!_hasFiles(e)) return; if (!_hasFiles(e)) return;
e.preventDefault(); e.preventDefault();
setRightPanelTab("files");
_dragDepth++; _dragDepth++;
$("file-droparea").classList.add("show"); $("file-droparea").classList.add("show");
}); });
@ -225,6 +262,7 @@ export function scheduleFilesRefresh() {
export async function loadFiles() { export async function loadFiles() {
try { try {
if (lastFilesData && lastFilesData.current !== state.filesPath) resetFileSearch();
const params = new URLSearchParams(); const params = new URLSearchParams();
if (state.filesPath) params.set("path", state.filesPath); if (state.filesPath) params.set("path", state.filesPath);
if (state.showHiddenDirs && state.taskId) { if (state.showHiddenDirs && state.taskId) {
@ -233,21 +271,50 @@ export async function loadFiles() {
} }
const qs = params.toString(); const qs = params.toString();
const data = await api("GET", "/v1/files" + (qs ? "?" + qs : "")); const data = await api("GET", "/v1/files" + (qs ? "?" + qs : ""));
lastFilesData = data;
renderFiles(data); renderFiles(data);
} catch (e) { } catch (e) {
if (e.status === 401) { logout(); return; } if (e.status === 401) { logout(); return; }
$("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`; $("file-crumbs").innerHTML = `<span class="muted">${escapeHtml(e.message)}</span>`;
$("file-list").innerHTML = ""; $("file-list").innerHTML = "";
lastFilesData = null;
} }
} }
// 切换文件面板浏览路径 // 切换文件面板浏览路径
function navFiles(newPath) { function navFiles(newPath) {
state.filesPath = newPath || ""; state.filesPath = newPath || "";
if (!state.filesPath) state.filesScope = "all";
resetFileSearch();
loadFiles(); 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) { function renderFiles(data) {
$("file-search").value = state.filesQuery;
$("file-sort").value = state.filesSort;
// 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文 // 当前所在的"项目"= 路径第一段;空路径 → user_root,无项目上下文
const segs = (data.current || "").split("/").filter(Boolean); const segs = (data.current || "").split("/").filter(Boolean);
const projName = segs[0] || ""; const projName = segs[0] || "";
@ -255,9 +322,19 @@ function renderFiles(data) {
const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName; const projShort = projName.length > 12 ? projName.slice(0, 11) + "…" : projName;
$("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)"; $("files-proj").textContent = projShort ? "· " + projShort : "· (根目录)";
$("files-proj").title = projName || data.root || ""; $("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"); const hiddenToggle = $("btn-toggle-hidden");
hiddenToggle.disabled = !currentInTask;
hiddenToggle.setAttribute("aria-pressed", state.showHiddenDirs ? "true" : "false"); hiddenToggle.setAttribute("aria-pressed", state.showHiddenDirs ? "true" : "false");
hiddenToggle.title = state.showHiddenDirs ? "隐藏点目录" : "显示点目录"; hiddenToggle.title = !currentInTask ? "技术目录仅在当前对话目录内可查看"
: (state.showHiddenDirs ? "隐藏技术目录" : "显示技术目录");
hiddenToggle.setAttribute("aria-label", hiddenToggle.title); hiddenToggle.setAttribute("aria-label", hiddenToggle.title);
// crumbs root 标"我的"(user_root),更直观;其余原样 // crumbs root 标"我的"(user_root),更直观;其余原样
const cr = data.crumbs.map((c, i) => { const cr = data.crumbs.map((c, i) => {
@ -280,15 +357,25 @@ function renderFiles(data) {
state.entriesByRel = {}; state.entriesByRel = {};
return; 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 = `<div class="empty">未找到“${escapeHtml(state.filesQuery.trim())}”</div>`;
state.entriesByRel = {};
return;
}
state.entriesByRel = {}; state.entriesByRel = {};
for (const e of data.entries) state.entriesByRel[e.rel] = e; for (const e of visibleEntries) state.entriesByRel[e.rel] = e;
$("file-list").innerHTML = data.entries.map((e) => { $("file-list").innerHTML = visibleEntries.map((e) => {
const cls = e.is_dir ? "ico-dir" : "ico-file"; const cls = e.is_dir ? "ico-dir" : "ico-file";
const fullTitle = e.rel || e.name; const fullTitle = e.rel || e.name;
return ` return `
<div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}"> <div class="file-row" data-rel="${escapeHtml(e.rel)}" title="${escapeHtml(fullTitle)}">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}"> <span class="file-main">
${escapeHtml(e.name)} <span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">${escapeHtml(e.name)}</span>
<span class="mtime">修改 ${escapeHtml(fileMtime(e.mtime))}</span>
</span> </span>
<span class="size">${humanSize(e.size)}</span> <span class="size">${humanSize(e.size)}</span>
<button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作"></button> <button class="dd-toggle file-menu" data-rel="${escapeHtml(e.rel)}" title="文件操作"></button>

View File

@ -1,6 +1,6 @@
// 三栏布局:pane 折叠(rail 模式 + localStorage)+ 拖拽 splitter + 手机单列视图。 // 三栏布局:pane 折叠(rail 模式 + localStorage)+ 拖拽 splitter + 手机单列视图。
// 顶层即绑定 toggle/splitter/mobile-tab 事件并应用初始状态(import 时执行,DOM 已就绪)。 // 右栏是应用内工作区:展开时在“文件 / 软件作业”之间切换,折叠时两个
// 只 setMobileView / mqPhone 对外(selectTask 在手机端选中任务时切到对话面板) // activity icon 仍可达。这一层不依赖外层 header标准 / embed 共用
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { LS_LEFT_COLLAPSED, LS_RIGHT_COLLAPSED, LS_LEFT_WIDTH, LS_RIGHT_WIDTH } from "./state.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); document.body.classList.toggle("right-collapsed", collapsed);
const btn = $("pane-toggle-right"); const btn = $("pane-toggle-right");
btn.textContent = collapsed ? "" : ""; 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 = () => { $("pane-toggle-left").onclick = () => {
const next = !document.body.classList.contains("left-collapsed"); const next = !document.body.classList.contains("left-collapsed");
@ -91,13 +124,14 @@ attachPaneSplitter("split-right", "right");
// body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class // body.mv-{left,mid,right} 控制当前显示的 pane;桌面下三 pane 都可见,本函数仅维护 class
// 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效) // 进入手机视口时清掉 collapsed(只 DOM,不动 localStorage —— 回桌面用户偏好仍生效)
export const mqPhone = window.matchMedia("(max-width: 640px)"); export const mqPhone = window.matchMedia("(max-width: 640px)");
export function setMobileView(view) { export function setMobileView(view, requestedRightTab = "") {
// view ∈ "mv-left" | "mv-mid" | "mv-right" // 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.remove("mv-left", "mv-mid", "mv-right");
document.body.classList.add(view); document.body.classList.add(view);
for (const b of document.querySelectorAll(".mobile-tabs button")) { syncMobileTabSelection(view);
b.classList.toggle("active", b.dataset.mv === view);
}
} }
function applyMobileMode() { function applyMobileMode() {
if (mqPhone.matches) { if (mqPhone.matches) {
@ -116,5 +150,9 @@ function applyMobileMode() {
mqPhone.addEventListener("change", applyMobileMode); mqPhone.addEventListener("change", applyMobileMode);
applyMobileMode(); applyMobileMode();
for (const b of document.querySelectorAll(".mobile-tabs button")) { 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 });

View File

@ -1,4 +1,4 @@
// 专业软件任务中心:右栏固定摘要、抽屉列表、游标分页和终态通知。 // 软件作业面板:右侧工作区列表、游标分页、活动数与终态未读通知。
import { api } from "./api.js"; import { api } from "./api.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
@ -9,6 +9,7 @@ import {
selectTask, selectTask,
} from "./chat.js"; } from "./chat.js";
import { dialogConfirm, message } from "./dialog.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 ACTIVE = new Set(["queued", "offered", "dispatched", "running", "disconnected", "cancelling"]);
const TERMINAL = new Set(["succeeded", "failed", "cancelled"]); const TERMINAL = new Set(["succeeded", "failed", "cancelled"]);
@ -19,9 +20,9 @@ let timer = null;
let known = new Map(); let known = new Map();
let jobs = []; let jobs = [];
let nextCursor = null; let nextCursor = null;
let expanded = false;
let loadingMore = false; let loadingMore = false;
let initialized = false; let initialized = false;
const unreadTerminal = new Set();
const statusLabel = { const statusLabel = {
queued: "等待计算节点", offered: "正在分配节点", dispatched: "节点已接收", queued: "等待计算节点", offered: "正在分配节点", dispatched: "节点已接收",
@ -57,15 +58,16 @@ function statusIcon(status) {
} }
export function initSoftwareJobs() { export function initSoftwareJobs() {
if (initialized || !$("software-job-center")) return; if (initialized || !$("software-job-list")) return;
initialized = true; initialized = true;
$("software-job-toggle").onclick = openDrawer; $("software-job-refresh").onclick = refreshSoftwareJobs;
$("software-job-close").onclick = closeDrawer;
$("software-job-backdrop").onclick = closeDrawer;
$("software-job-list").addEventListener("scroll", onListScroll); $("software-job-list").addEventListener("scroll", onListScroll);
document.addEventListener("software-job-submitted", refreshSoftwareJobs); document.addEventListener("software-job-submitted", refreshSoftwareJobs);
document.addEventListener("keydown", (event) => { document.addEventListener("right-panel-changed", (event) => {
if (event.key === "Escape" && expanded) closeDrawer(); if (event.detail?.tab === "jobs") {
unreadTerminal.clear();
render();
}
}); });
refreshSoftwareJobs(); refreshSoftwareJobs();
} }
@ -146,31 +148,26 @@ document.addEventListener("visibilitychange", () => {
if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule(); if (!document.hidden && state.token) refreshSoftwareJobs(); else schedule();
}); });
function openDrawer() {
expanded = true;
render();
}
function closeDrawer() {
expanded = false;
render();
}
function render() { function render() {
const active = jobs.filter((job) => ACTIVE.has(job.status)); const active = jobs.filter((job) => ACTIVE.has(job.status));
const latest = jobs[0]; const latest = jobs[0];
const unreadJob = jobs.find((job) => unreadTerminal.has(job.job_id));
const entryDot = $("software-job-entry-dot"); const entryDot = $("software-job-entry-dot");
entryDot.className = "sj-entry-dot"; entryDot.className = "sj-entry-dot";
if (active.length) entryDot.classList.add("active"); if (unreadJob?.status === "failed") entryDot.classList.add("failed");
else if (latest?.status === "succeeded") entryDot.classList.add("succeeded"); else if (unreadJob) entryDot.classList.add("succeeded");
else if (latest?.status === "failed") entryDot.classList.add("failed"); 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 $("software-job-count").textContent = active.length
? `${active.length} 个进行中 · ${statusLabel[active[0].status]}` ? `${active.length} 个进行中 · ${statusLabel[active[0].status]}`
: (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无任务"); : (latest ? `最近:${statusLabel[latest.status] || latest.status}` : "暂无作业");
$("software-job-panel").classList.toggle("open", expanded); renderList();
$("software-job-panel").setAttribute("aria-hidden", String(!expanded));
$("software-job-backdrop").classList.toggle("open", expanded);
if (expanded) renderList();
} }
function renderList() { function renderList() {
@ -313,7 +310,8 @@ async function handleAction(event, button) {
const taskId = button.dataset.taskId; const taskId = button.dataset.taskId;
if (action === "results") { if (action === "results") {
await openSoftwareJobResults(taskId, jobs.find((item) => item.job_id === jobId)?.output_dir || ""); await openSoftwareJobResults(taskId, jobs.find((item) => item.job_id === jobId)?.output_dir || "");
closeDrawer(); setRightPanelTab("files");
if (mqPhone.matches) setMobileView("mv-right", "files");
return; return;
} }
if (action === "analyze") { if (action === "analyze") {
@ -323,7 +321,6 @@ async function handleAction(event, button) {
if (taskId) await selectTask(taskId); if (taskId) await selectTask(taskId);
await refreshCurrentTaskAfterSoftwareJob(taskId); await refreshCurrentTaskAfterSoftwareJob(taskId);
message("已排队分析结果", "success"); message("已排队分析结果", "success");
closeDrawer();
refreshSoftwareJobs(); refreshSoftwareJobs();
} catch (error) { } catch (error) {
button.disabled = false; button.disabled = false;
@ -332,13 +329,13 @@ async function handleAction(event, button) {
return; return;
} }
if (taskId) await selectTask(taskId); if (taskId) await selectTask(taskId);
closeDrawer();
} }
function notifyTerminal(job) { function notifyTerminal(job) {
const ok = job.status === "succeeded"; const ok = job.status === "succeeded";
const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败"); const label = ok ? "已完成" : (job.status === "cancelled" ? "已取消" : "失败");
const summary = job.request_summary || {}; 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); message(`${summary.display_name || "专业软件任务"}${label}`, ok ? "success" : "error", 6000);
void refreshCurrentTaskAfterSoftwareJob(job.task_id); void refreshCurrentTaskAfterSoftwareJob(job.task_id);
} }

View File

@ -48,6 +48,9 @@ export const state = {
draftNewDirMode: false, draftNewDirMode: false,
draftModelProfile: "", draftModelProfile: "",
filesPath: "", filesPath: "",
filesScope: "all", // all=用户根目录视图task=跟随当前对话工作目录
filesQuery: "", // 仅过滤当前已浏览目录
filesSort: "name-asc",
showHiddenDirs: false, // 仅当前 task 工作目录子树生效user_root 永不展示 showHiddenDirs: false, // 仅当前 task 工作目录子树生效user_root 永不展示
// 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示 // 同 wd 内除自己外其他活跃 task(run_status in running/cancelling),供 banner 显示
concurrentWarnings: [], concurrentWarnings: [],