refactor(dev): 前端模块化 Step 2 — 抽出 newtask.js(新建任务弹框)

任务名 / 工作目录(新建 sentinel 或复用已有 + 二级 input 联动)/ 描述 /
skill / 模型 select,提交 POST /v1/tasks(原 main 1146–1320)→
newtask.js(186 行)。

- 顶层自绑 hd-new 打开 / nt-go 提交 / 各 input 联动;唯一对外导出
  loadFolderSuggestions(供 main enterApp 初始化顶部 filter-wd、files
  复制/移动后刷目录)—— 它从 main 迁来后,files.js 对它的 import 从
  ./main.js 改指 ./newtask.js。
- 反向 import main glue loadModels(加 export)/ loadTaskList / selectTask
  + logout(auth)。

main.js 删至 1220 行。node 全检过、import/export 一致性校验过、
私有符号清零。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-07 19:24:29 +08:00
parent e5940266ca
commit 40fefdffef
4 changed files with 191 additions and 178 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-06-07(前端模块化 Step 2:抽出 layout / auth / preview / files / media.js)
最后更新:2026-06-07(前端模块化 Step 2:抽出 … / media / newtask.js)
---
@ -23,6 +23,7 @@
### 2026-06-06
- **前端模块化 Step 2:抽出 `newtask.js`(新建任务弹框)**:任务名 / 工作目录(新建 sentinel 或复用已有 + 二级 input 联动)/ 描述 / skill / 模型 select,提交 `POST /v1/tasks`(原 main.js 11461320)→ `newtask.js`(186 行)。顶层自绑 hd-new 打开 / nt-go 提交 / 各 input 联动;唯一对外导出 `loadFolderSuggestions`(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷目录)——它从 main 迁来后,`files.js` 对它的 import 从 `./main.js` 改指 `./newtask.js`。反向 import main glue `loadModels`(加 `export`)/`loadTaskList`/`selectTask` + `logout`(auth)。main.js 删至 1220 行。node 全检过、import/export 一致性校验过、私有符号清零。
- **前端模块化 Step 2:抽出 `media.js`(工具活动标签 + artifact 抽取/渲染)+ 收敛 downloadFile 反向依赖**:对话内 `toolActivityLabel`(工具调用→中文活动名)、`extractArtifactRels`(从结果文本/working_dir 提产物路径)、`extractMediaBanner`(seedream/seedance 横幅)、`renderArtifactBarHtml`(产物 chip 条 + 图/视频内联占位)、`upgradeMediaArtifacts`(占位异步 fetch blob 填 `<img>`/`<video>` 带缓存)、`downloadFile`(blob 下载)→ `media.js`(237 行,原 main.js 11341359)。**收敛点**:downloadFile 移入 media 后,`preview.js`/`files.js` 对它的 import 从 `./main.js` 改指 `./media.js` —— 把这条反向依赖从 main 挪开。media 导入极少(`escapeHtml`/`_categorize`(preview)/`state`/`logout`),与 preview 成 media↔preview 环(均运行时调用,安全)。**两次险漏靠校验抓回**:① 共享 const `ARTIFACT_PRODUCING_TOOLS`(main renderMessages/SSE 用 4 处,`.has()` 访问非函数调用,"被调标识符"法漏掉)② 内部函数 `_flushMediaArtifactCache`(selectTask 切任务清缓存用)—— 残留符号检查发现后补 export。新增**全模块 import/export 一致性校验脚本**(每个 `import{X}` 必在目标 `export`),11 模块全过。main.js 删至 1393 行。`node --check` 11 模块全过、静态测试 2 过。
- **前端模块化 Step 2:抽出 `files.js`(文件面板 + 选入 + 拖拽上传)**:右栏文件列表浏览/导航/删除/重命名 + 刷新 + "选入"弹框(跨目录勾选复制/移动)+ 拖拽 overlay + 上传(XHR 带进度)+ 上传状态条。代码原分散在 main.js **两段非连续区**(11331459 文件列表/选入/拖拽 + 16971786 上传 helper,中间夹着 media 段)→ 合并进 `files.js`(433 行)。导出 `loadFiles`/`scheduleFilesRefresh`(SSE 文件事件刷新)/`closeSrcPicker`(main Esc 关栈)/`uploadFiles`(聊天区粘贴/拖拽复用);其余入口模块顶层自绑。反向 import `openFilePreview`(preview)、`logout`(auth)、main glue `downloadFile`/`selectTask`/`loadTaskList`/`loadFolderSuggestions`(后三个加 `export`,后续随 tasks/newtask 模块化再迁)。依赖分析用"段内被调标识符 段内定义 叶子/全局"全量提取,补回固定清单漏掉的 `loadFolderSuggestions`/`loadTaskList`。main.js 删至 1619 行。`node --check` 双过、main 残留 files 私有符号清零、files 无未导入 glue、静态测试 2 过。
- **前端模块化 Step 2:抽出 `preview.js`(文件预览 + mini 预览)**:文件预览主弹框(图/视频/PDF/文本/markdown/docx/xlsx,大文件降级下载,docx/xlsx 走 `loadScript` 懒加载 vendor)+ 同时再开的小窗预览(原 main.js 16872048)→ `preview.js`(379 行)。导出 `openFilePreview`/`openPasteFilePreview`/`closeFilePreview`/`closeMiniPreview`/`_categorize`(媒体段判图/视频用)。反向 import `downloadFile`(main 媒体段,加 `export`)、`logout`(auth)。**Esc 关弹窗栈处理器留 main**(跨模块协调 chpw/选入/文件预览/小预览,加了节注释)。**一处去耦**:`deletePastedFile`(留 main)原直接读 preview 私有 `_fpCurrentRel`/`_mpCurrentRel` 判断要不要关预览 → 改为 preview 导出封装 `closePreviewIfShowing(rel)`,行为不变但不泄漏内部 current-rel 状态(模块边界更干净;唯一非纯剪切的微调)。main.js 删至 2034 行。`node --check` 双过、preview 私有符号在 main 清零、无未导入 glue 引用、静态测试 2 过。

View File

@ -11,7 +11,8 @@ import { escapeHtml, humanSize } from "./format.js";
import { openFilePreview } from "./preview.js";
import { logout } from "./auth.js";
import { downloadFile } from "./media.js";
import { selectTask, loadTaskList, loadFolderSuggestions } from "./main.js";
import { selectTask, loadTaskList } from "./main.js";
import { loadFolderSuggestions } from "./newtask.js";
// ───── files(user-rooted,不绑 task) ─────
$("btn-refresh-files").onclick = () => loadFiles();

View File

@ -18,6 +18,7 @@ import { logout, closeChpwModal } from "./auth.js";
import { openFilePreview, openPasteFilePreview, closeFilePreview, closeMiniPreview, closePreviewIfShowing, _categorize } from "./preview.js";
import { loadFiles, scheduleFilesRefresh, closeSrcPicker, uploadFiles } from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
import { loadFolderSuggestions } from "./newtask.js";
// embed 首个 task 自动定位的一次性标志(仅 embed 段使用)
let _embedInitialTaskHandled = false;
@ -62,7 +63,7 @@ async function loadStorage() {
el.classList.add("show");
}
async function loadModels() {
export async function loadModels() {
try {
const data = await api("GET", "/v1/models");
state.models = data.models || [];
@ -1143,181 +1144,6 @@ document.addEventListener("keydown", (e) => {
});
// ───── new task ─────
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
let wdManuallyEdited = false;
$("hd-new").onclick = async () => {
$("nt-name").value = "";
$("nt-wd-sel").value = "__new__"; // 默认选 sentinel
$("nt-wd-new").value = "";
$("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
wdManuallyEdited = false;
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
$("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次
populateModelSelect();
$("nt-name").focus();
};
function populateModelSelect() {
const sel = $("nt-model");
const models = state.models || [];
if (models.length === 0) {
sel.innerHTML = `<option value="">(默认)</option>`;
return;
}
sel.innerHTML = models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
}
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
const sel = $("nt-wd-sel").value;
// sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value
const working_dir = sel === "__new__"
? ($("nt-wd-new").value.trim() || name)
: sel;
const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value;
const model_profile = $("nt-model").value;
$("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
try {
const t = await api("POST", "/v1/tasks",
{ name, working_dir, description: desc, skill, model_profile });
$("new-task-modal").classList.remove("show");
await loadTaskList();
selectTask(t.task_id);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("nt-err").textContent = e.message;
}
};
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel)
export async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
state.folders = data.folders || [];
} catch (e) {
state.folders = state.folders || [];
}
populateFolderSelects();
}
// 灌 filter-wd + nt-wd-sel options;保留当前选中值
function populateFolderSelects() {
const folders = state.folders || [];
// 顶部 filter:第一项 "(全部目录)" sentinel
const filterSel = $("filter-wd");
const filterCur = filterSel.value;
const filterOpts = ['<option value="">(全部目录)</option>'];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
filterOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
filterSel.innerHTML = filterOpts.join("");
filterSel.value = filterCur; // 重渲后恢复选中
// modal wd:第一项 "+ 新建(跟随任务名)" sentinel(label 由 updateSentinelLabel 实时刷)
const wdSel = $("nt-wd-sel");
const wdCur = wdSel.value || "__new__";
const wdOpts = [`<option value="__new__">+ 新建(跟随任务名)</option>`];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
wdOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
wdSel.innerHTML = wdOpts.join("");
wdSel.value = wdCur;
updateSentinelLabel(); // 用最新的 name 刷 sentinel
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
async function loadSkillOptions() {
const sel = $("nt-skill");
if (!state.skills) {
try {
const data = await api("GET", "/v1/skills");
state.skills = data.skills || [];
} catch (e) {
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
}
}
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
const opts = ['<option value="">(默认 · 不限定)</option>'];
for (const s of state.skills) {
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
}
sel.innerHTML = opts.join("");
sel.value = ""; // hd-new 已清空,这里幂等再保一次
}
// === modal wd select + 二级 input 联动 ===
// select 选 "__new__" sentinel → 显示二级 input(默认值跟随 name);选已有目录 → 隐藏二级 input
// wdManuallyEdited:用户改过二级 input 后置 true,name 不再覆盖;清空二级 input 重置 false
function updateSentinelLabel() {
// sentinel 永远是 select 第一项,labels 实时含 name 让用户一眼知会建什么
const sel = $("nt-wd-sel");
const opt = sel.options[0];
if (!opt || opt.value !== "__new__") return;
const name = $("nt-name").value.trim();
opt.textContent = name ? `+ 新建「${name}` : `+ 新建(跟随任务名)`;
}
function updateWdHint() {
const hint = $("nt-wd-hint");
const sel = $("nt-wd-sel").value;
if (sel === "__new__") {
const v = $("nt-wd-new").value.trim();
const name = $("nt-name").value.trim();
const target = v || name;
if (!target) { hint.textContent = ""; return; }
// 用户手输的新名恰好命中已有目录 → 提示会复用而非新建
const collision = (state.folders || []).find(f => f.name === target);
if (collision) {
const n = collision.n_tasks || 0;
hint.innerHTML = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`;
}
} else {
const f = (state.folders || []).find(x => x.name === sel);
const n = f ? (f.n_tasks || 0) : 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
}
}
// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name
$("nt-name").addEventListener("input", () => {
updateSentinelLabel();
if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") {
$("nt-wd-new").value = $("nt-name").value;
}
updateWdHint();
});
// wd select 切换 → 切显示二级 input + 刷 hint
$("nt-wd-sel").addEventListener("change", () => {
const v = $("nt-wd-sel").value;
if (v === "__new__") {
$("nt-wd-new").style.display = "";
if (!wdManuallyEdited) $("nt-wd-new").value = $("nt-name").value;
} else {
$("nt-wd-new").style.display = "none";
}
updateWdHint();
});
// 二级 input 改变 → 非空视为手动修改;清空重置 flag 但保持空(避免 backspace 想换名时被打断)
$("nt-wd-new").addEventListener("input", () => {
wdManuallyEdited = $("nt-wd-new").value.trim() !== "";
updateWdHint();
});
// ───── embed mode ─────
export function embedPostToParent(msg) {
if (!EMBED_PARENT_ORIGIN || window.parent === window) return;

185
web/static/js/newtask.js Normal file
View File

@ -0,0 +1,185 @@
// 新建任务弹框:任务名 / 工作目录(新建 sentinel 或复用已有,二级 input 联动)/
// 描述 / 智能体(skill)/ 模型 select,提交 POST /v1/tasks。
// 顶层自绑 hd-new 打开、nt-go 提交、各 input 联动;唯一对外导出 loadFolderSuggestions
//(供 main enterApp 初始化顶部 filter-wd、files 复制/移动后刷新目录列表)。
import { state } from "./state.js";
import { api } from "./api.js";
import { escapeHtml } from "./format.js";
import { logout } from "./auth.js";
import { loadModels, loadTaskList, selectTask } from "./main.js";
// ───── new task ─────
// wd 二级 input 默认跟随 name;用户一旦手改二级 input → 脱钩;清空二级 input 重置 flag
let wdManuallyEdited = false;
$("hd-new").onclick = async () => {
$("nt-name").value = "";
$("nt-wd-sel").value = "__new__"; // 默认选 sentinel
$("nt-wd-new").value = "";
$("nt-wd-new").style.display = ""; // sentinel 选中态 → 二级 input 可见
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
wdManuallyEdited = false;
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
$("nt-wd-sel").value = "__new__"; // populateFolderSelects 重渲后再保险一次
populateModelSelect();
$("nt-name").focus();
};
function populateModelSelect() {
const sel = $("nt-model");
const models = state.models || [];
if (models.length === 0) {
sel.innerHTML = `<option value="">(默认)</option>`;
return;
}
sel.innerHTML = models.map(m =>
`<option value="${escapeHtml(m.profile)}" ${m.is_default ? "selected" : ""}>${escapeHtml(m.display_name)}</option>`
).join("");
}
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
const sel = $("nt-wd-sel").value;
// sentinel:用二级 input 值,空则 fallback name;选已有目录:直接用 value
const working_dir = sel === "__new__"
? ($("nt-wd-new").value.trim() || name)
: sel;
const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value;
const model_profile = $("nt-model").value;
$("nt-err").textContent = "";
if (!name) { $("nt-err").textContent = "任务名为必填项"; return; }
try {
const t = await api("POST", "/v1/tasks",
{ name, working_dir, description: desc, skill, model_profile });
$("new-task-modal").classList.remove("show");
await loadTaskList();
selectTask(t.task_id);
} catch (e) {
if (e.status === 401) { logout(); return; }
$("nt-err").textContent = e.message;
}
};
// 工作目录:拉数据 + 灌两个 select(顶部 filter-wd 和 modal nt-wd-sel)
export async function loadFolderSuggestions() {
try {
const data = await api("GET", "/v1/folders");
state.folders = data.folders || [];
} catch (e) {
state.folders = state.folders || [];
}
populateFolderSelects();
}
// 灌 filter-wd + nt-wd-sel options;保留当前选中值
function populateFolderSelects() {
const folders = state.folders || [];
// 顶部 filter:第一项 "(全部目录)" sentinel
const filterSel = $("filter-wd");
const filterCur = filterSel.value;
const filterOpts = ['<option value="">(全部目录)</option>'];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
filterOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
filterSel.innerHTML = filterOpts.join("");
filterSel.value = filterCur; // 重渲后恢复选中
// modal wd:第一项 "+ 新建(跟随任务名)" sentinel(label 由 updateSentinelLabel 实时刷)
const wdSel = $("nt-wd-sel");
const wdCur = wdSel.value || "__new__";
const wdOpts = [`<option value="__new__">+ 新建(跟随任务名)</option>`];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
wdOpts.push(`<option value="${escapeHtml(f.name)}">${escapeHtml(f.name)}${escapeHtml(tag)}</option>`);
}
wdSel.innerHTML = wdOpts.join("");
wdSel.value = wdCur;
updateSentinelLabel(); // 用最新的 name 刷 sentinel
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
async function loadSkillOptions() {
const sel = $("nt-skill");
if (!state.skills) {
try {
const data = await api("GET", "/v1/skills");
state.skills = data.skills || [];
} catch (e) {
state.skills = []; // 静默兜底,select 仍保留"(默认)"项
}
}
// 渲染:第一项固定为"默认"(空 value),其后逐 skill 一项
const opts = ['<option value="">(默认 · 不限定)</option>'];
for (const s of state.skills) {
const label = `${s.name}${s.description ? " — " + s.description : ""}`;
opts.push(`<option value="${escapeHtml(s.name)}" title="${escapeHtml(s.description || "")}">${escapeHtml(label)}</option>`);
}
sel.innerHTML = opts.join("");
sel.value = ""; // hd-new 已清空,这里幂等再保一次
}
// === modal wd select + 二级 input 联动 ===
// select 选 "__new__" sentinel → 显示二级 input(默认值跟随 name);选已有目录 → 隐藏二级 input
// wdManuallyEdited:用户改过二级 input 后置 true,name 不再覆盖;清空二级 input 重置 false
function updateSentinelLabel() {
// sentinel 永远是 select 第一项,labels 实时含 name 让用户一眼知会建什么
const sel = $("nt-wd-sel");
const opt = sel.options[0];
if (!opt || opt.value !== "__new__") return;
const name = $("nt-name").value.trim();
opt.textContent = name ? `+ 新建「${name}` : `+ 新建(跟随任务名)`;
}
function updateWdHint() {
const hint = $("nt-wd-hint");
const sel = $("nt-wd-sel").value;
if (sel === "__new__") {
const v = $("nt-wd-new").value.trim();
const name = $("nt-name").value.trim();
const target = v || name;
if (!target) { hint.textContent = ""; return; }
// 用户手输的新名恰好命中已有目录 → 提示会复用而非新建
const collision = (state.folders || []).find(f => f.name === target);
if (collision) {
const n = collision.n_tasks || 0;
hint.innerHTML = `<span style="color:var(--accent);">! 已有同名目录,将复用</span> · ${n} 个任务`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(target)}</span>`;
}
} else {
const f = (state.folders || []).find(x => x.name === sel);
const n = f ? (f.n_tasks || 0) : 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
}
}
// name 改变 → 更新 sentinel label;若未脱钩且当前是 sentinel,二级 input 跟随 name
$("nt-name").addEventListener("input", () => {
updateSentinelLabel();
if (!wdManuallyEdited && $("nt-wd-sel").value === "__new__") {
$("nt-wd-new").value = $("nt-name").value;
}
updateWdHint();
});
// wd select 切换 → 切显示二级 input + 刷 hint
$("nt-wd-sel").addEventListener("change", () => {
const v = $("nt-wd-sel").value;
if (v === "__new__") {
$("nt-wd-new").style.display = "";
if (!wdManuallyEdited) $("nt-wd-new").value = $("nt-name").value;
} else {
$("nt-wd-new").style.display = "none";
}
updateWdHint();
});
// 二级 input 改变 → 非空视为手动修改;清空重置 flag 但保持空(避免 backspace 想换名时被打断)
$("nt-wd-new").addEventListener("input", () => {
wdManuallyEdited = $("nt-wd-new").value.trim() !== "";
updateWdHint();
});