feat(web): 集中项目管理与文件导航

This commit is contained in:
caoqianming 2026-09-04 14:51:53 +08:00
parent f748052775
commit c754afcb7b
6 changed files with 154 additions and 19 deletions

View File

@ -6,6 +6,10 @@
> 开发中的用户文案可先写入 `## Unreleased`;该区不会被前端解析,正式发布时再替换为数字版本和日期。
> 工程口径的完整记录见 `PROGRESS.md` / git log。
## Unreleased
- 项目管理集中到左侧:支持直接创建、重命名、查看文件和安全移除空项目;右侧入口改为“当前项目 / 全部文件”,专注项目内及跨项目文件浏览。
## 0.72.1 — 2026-09-04
- 左侧导航改为“项目→对话”分组,并保留最近对话和筛选结果;右侧文件范围改用“当前项目 / 全部项目”表达,更容易看清同一项目下的多个对话和共享资料。

View File

@ -6,6 +6,7 @@ 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 previewJs = fs.readFileSync("web/static/js/preview.js", "utf8");
const newTaskJs = fs.readFileSync("web/static/js/newtask.js", "utf8");
const pageHtml = fs.readFileSync("web/static/dev.html", "utf8");
test("file menu exposes an explicit confirmed replacement action", () => {
@ -24,7 +25,7 @@ test("ordinary uploads default to rename conflict policy", () => {
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(pageHtml, /id="files-scope-all"[^>]*>全部文件</);
assert.match(filesJs, /state\.filesScope = "task"/);
assert.match(filesJs, /state\.filesScope = "all"/);
});
@ -39,6 +40,17 @@ test("task navigation groups chats by project with restrained icon sizes", () =>
assert.match(chatJs, /working_dir: projectName/);
assert.match(chatJs, /state\.taskHasMore = filtered &&/);
assert.match(chatJs, /task-project-more/);
assert.match(chatJs, /task-project-create/);
assert.doesNotMatch(chatJs, /if \(!folder\.n_tasks\) continue/);
assert.match(chatJs, /renameProjectFolder/);
assert.match(chatJs, /removeProjectFolder/);
assert.match(chatJs, /openProjectFiles/);
assert.match(filesJs, /function createProjectFolder/);
assert.match(filesJs, /function removeProjectFolder/);
assert.match(chatJs, /folders-refresh-requested/);
assert.match(newTaskJs, /folders-refresh-requested/);
assert.match(filesJs, /setRightPanelTab\("files"\)/);
assert.match(filesJs, /recursive: entries\.length > 0/);
assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/);
assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/);
});

View File

@ -685,11 +685,20 @@
.task-nav-recent { padding: 8px 0 6px; border-bottom: 1px solid var(--border-soft); }
.task-nav-section-title { min-height: 28px; padding: 4px 12px; display: flex; align-items: center; gap: 7px;
color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .02em; }
.task-nav-section-title .spacer { flex: 1; }
.task-project-create { width: 24px; height: 24px; padding: 0; border: 0; border-radius: 5px;
background: transparent; color: var(--muted); font-size: 17px; line-height: 1; cursor: pointer; }
.task-project-create:hover { background: var(--hover); color: var(--text); }
.task-projects { padding: 6px 0 10px; }
.task-project { margin: 1px 0; }
.task-project-head { width: 100%; min-height: 34px; padding: 4px 12px 4px 8px; border: 0; border-radius: 0;
.task-project-row { display: flex; align-items: center; }
.task-project-head { flex: 1; min-width: 0; min-height: 34px; padding: 4px 4px 4px 8px; border: 0; border-radius: 0;
background: transparent; color: var(--text); display: flex; align-items: center; gap: 6px; cursor: pointer; text-align: left; }
.task-project-head:hover { background: var(--hover); }
.task-project-row:hover { background: var(--hover); }
.task-project-menu { width: 28px; height: 28px; margin-right: 6px; padding: 0; flex: none; border: 0;
background: transparent; color: var(--muted); opacity: 0; cursor: pointer; }
.task-project-row:hover .task-project-menu, .task-project-menu:focus-visible { opacity: 1; }
@media (hover: none) { .task-project-menu { opacity: .65; } }
.task-project-chevron { width: 12px; height: 18px; display: inline-flex; align-items: center; justify-content: center;
flex: none; color: var(--muted); font-size: 15px; line-height: 1; transition: transform .14s ease; }
.task-project.open .task-project-chevron { transform: rotate(90deg); }
@ -2379,7 +2388,7 @@
<div id="right-files-panel" class="right-workspace-panel" role="tabpanel" aria-labelledby="right-tab-files">
<div id="file-context" class="file-context">
<button id="files-scope-task" type="button" class="active">当前项目</button>
<button id="files-scope-all" type="button">全部项目</button>
<button id="files-scope-all" type="button" title="跨项目浏览、复制和移动文件">全部文件</button>
<span id="files-proj" class="muted small" title=""></span>
</div>
<div class="file-tools">

View File

@ -46,7 +46,16 @@ import {
renderWebPreviewCardHtml,
stopWebPreview,
} from "./preview.js";
import { loadFiles, scheduleFilesRefresh, uploadFiles, formatUploadProgress } from "./files.js";
import {
createProjectFolder,
formatUploadProgress,
loadFiles,
openProjectFiles,
removeProjectFolder,
renameProjectFolder,
scheduleFilesRefresh,
uploadFiles,
} from "./files.js";
import { toolActivityLabel, _workingDirName, extractMediaBanner, extractArtifactRels, renderArtifactBarHtml, renderArtifactChipContent, upgradeMediaArtifacts, ARTIFACT_PRODUCING_TOOLS, _flushMediaArtifactCache } from "./media.js";
import { parseUserAttachments } from "./attachments.js";
import {
@ -290,7 +299,6 @@ function taskProjectGroups(tasks) {
const groups = [];
const seen = new Set();
for (const folder of state.folders || []) {
if (!folder.n_tasks) continue; // 空目录仍在右栏「全部项目」中管理
groups.push({
key: folder.name,
name: folder.name,
@ -341,16 +349,26 @@ function groupedTaskListHtml(tasks) {
const cachedPage = _projectTaskPages.get(group.key);
const exactCount = cachedPage && cachedPage.page > 0 ? cachedPage.count : group.count;
return `<section class="task-project${open ? " open" : ""}" data-project-key="${escapeHtml(group.key)}">
<div class="task-project-row">
<button class="task-project-head" type="button" aria-expanded="${open}">
<span class="task-project-chevron" aria-hidden="true"></span>
${taskNavIcon("folder")}
<span class="task-project-name" title="${escapeHtml(group.name)}">${escapeHtml(group.name)}</span>
<span class="task-project-count">${exactCount}</span>
</button>
${group.key === "__ungrouped__" ? "" : `<button class="task-project-menu dd-toggle" type="button"
data-project-name="${escapeHtml(group.name)}" data-project-count="${exactCount}"
aria-label="管理项目 ${escapeHtml(group.name)}" title="项目操作"></button>`}
</div>
<div class="task-project-items">${projectItemsHtml(group)}</div>
</section>`;
}).join("");
return `${recentHtml}<div class="task-projects">${projectsHtml}</div>`;
const projectTitle = `<div class="task-nav-section-title">
<span>项目</span><span class="spacer"></span>
<button id="task-project-create" class="task-project-create" type="button" title="创建项目" aria-label="创建项目"></button>
</div>`;
const projectBody = projectsHtml || `<div class="task-project-state">暂无项目</div>`;
return `${recentHtml}${projectTitle}<div class="task-projects">${projectBody}</div>`;
}
async function loadProjectTasks(projectName, { append = false } = {}) {
@ -408,12 +426,11 @@ async function loadProjectTasks(projectName, { append = false } = {}) {
function renderTaskNavigation() {
const listEl = $("task-list");
if (!_globalTaskResults.length) {
listEl.innerHTML = `<div class="empty">(暂无对话)</div>`;
return;
}
if (isTaskListFiltered()) {
listEl.innerHTML = `<div class="task-filter-results"><div class="task-nav-section-title">筛选结果</div>${_globalTaskResults.map((t) => taskRowHtml(t)).join("")}</div>`;
const results = _globalTaskResults.length
? _globalTaskResults.map((t) => taskRowHtml(t)).join("")
: `<div class="empty">(无匹配对话)</div>`;
listEl.innerHTML = `<div class="task-filter-results"><div class="task-nav-section-title">筛选结果</div>${results}</div>`;
} else {
listEl.innerHTML = groupedTaskListHtml(_globalTaskResults);
}
@ -421,10 +438,30 @@ function renderTaskNavigation() {
}
export function refreshTaskProjectIndex() {
if (!isTaskListFiltered() && _globalTaskResults.length) renderTaskNavigation();
if (!isTaskListFiltered()) renderTaskNavigation();
}
function bindTaskListInteractions(root) {
const createButton = root.querySelector("#task-project-create");
if (createButton) createButton.onclick = () => createProjectFolder();
root.querySelectorAll(".task-project-menu").forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
const name = button.dataset.projectName || "";
const count = Number(button.dataset.projectCount) || 0;
showMenu(button, [
{ act: "open-files", label: "查看项目文件", onclick: () => openProjectFiles(name) },
{ act: "rename", label: "重命名项目", cls: "act-rename", onclick: () => renameProjectFolder(name) },
{
act: "delete",
label: count ? `移除项目(尚有 ${count} 个对话)` : "移除项目",
cls: "act-delete",
disabled: count > 0,
onclick: () => removeProjectFolder(name),
},
]);
};
});
root.querySelectorAll(".task-row").forEach((el) => {
el.onclick = (e) => {
if (e.target.closest(".dd-toggle")) return;
@ -3281,6 +3318,7 @@ async function createTaskFromDraft() {
applyChannelComposerLock(t);
$("chat-attach-btn").disabled = false;
loadTaskList(); // 新 task 立即进左栏;不等待列表 RTT 再发首条消息
document.dispatchEvent(new CustomEvent("folders-refresh-requested"));
loadFiles();
return t;
}
@ -4045,6 +4083,7 @@ async function deleteTask(tid, name, nMsg) {
await showNewConversationDraft();
}
loadTaskList();
document.dispatchEvent(new CustomEvent("folders-refresh-requested"));
loadFiles();
} catch (e) {
if (e.status === 401) { logout(); return; }

View File

@ -91,6 +91,72 @@ async function createFolder() {
}
}
export async function createProjectFolder() {
const rawName = await dialogPrompt({
title: "创建项目", label: "项目名称", placeholder: "输入项目名称", maxLength: 255,
okText: "创建",
});
if (rawName == null) return;
const name = rawName.trim();
if (!name) return;
try {
await api("POST", "/v1/files/mkdir", { path: "", name });
await loadFolderSuggestions();
if (state.filesScope === "all" && !state.filesPath) await loadFiles();
message(`已创建项目「${name}`, "success");
} catch (e) {
if (e.status === 401) { logout(); return; }
message("创建项目失败:" + e.message, "error");
}
}
export async function renameProjectFolder(name) {
if (!name) return;
await renameFile(name, name, true);
}
export function openProjectFiles(name) {
if (!name) return;
state.filesScope = "all";
state.filesPath = name;
resetFileSearch();
setRightPanelTab("files");
loadFiles();
}
export async function removeProjectFolder(name) {
if (!name) return;
let entries;
try {
const data = await api("GET", "/v1/files?path=" + encodeURIComponent(name));
entries = data.entries || [];
} catch (e) {
if (e.status === 401) { logout(); return; }
message("读取项目失败:" + e.message, "error");
return;
}
const detail = entries.length
? `项目「${name}」包含 ${entries.length} 项文件或目录,移除后不可恢复。`
: `确认移除空项目「${name}」?`;
if (!await dialogConfirm({
title: "移除项目", message: detail, okText: "确认移除", danger: true,
})) return;
try {
await api("POST", "/v1/files/delete", { path: name, recursive: entries.length > 0 });
if (state.filesPath === name || state.filesPath.startsWith(name + "/")) {
state.filesScope = "all";
state.filesPath = "";
resetFileSearch();
}
await loadFolderSuggestions();
await loadFiles();
message(`已移除项目「${name}`, "success");
} catch (e) {
if (e.status === 401) { logout(); return; }
message("移除项目失败:" + e.message, "error");
}
}
// ───── 选入 modal(勾源 → 复制 / 移动到主区当前目录)─────
// 设计:目的地永远是主区 state.filesPath。弹框内浏览的 path 跟主区独立 — 用户从 A 翻到 B
// 勾几个,再翻到 C 接着勾,跨目录 selection 用 Set<rel> 全程保留;切换浏览路径不清空。

View File

@ -60,6 +60,7 @@ $("nt-go").onclick = async () => {
const t = await api("POST", "/v1/tasks", body);
localStorage.setItem(LS_RECENT_WORKING_DIR, working_dir);
$("new-task-modal").classList.remove("show");
await loadFolderSuggestions();
await loadTaskList();
selectTask(t.task_id);
} catch (e) {
@ -81,6 +82,10 @@ export async function loadFolderSuggestions() {
refreshTaskProjectIndex();
}
// task 的创建/软删除发生在 chat.js用事件保持目录下拉和左侧项目计数同步
// 避免在 chat.js ↔ newtask.js 的既有依赖环上再增加反向 import。
document.addEventListener("folders-refresh-requested", () => { void loadFolderSuggestions(); });
// 灌顶部 filter-wd自定义创建弹框使用与快速新对话一致的组合框。
function populateFolderSelects() {
const folders = state.folders || [];