fix(web): 优化项目分页与文件图标

This commit is contained in:
caoqianming 2026-09-04 14:07:14 +08:00
parent dcc2918d60
commit f748052775
5 changed files with 244 additions and 29 deletions

View File

@ -35,10 +35,29 @@ test("task navigation groups chats by project with restrained icon sizes", () =>
assert.match(chatJs, /task-nav-recent/); assert.match(chatJs, /task-nav-recent/);
assert.match(chatJs, /task-project-head/); assert.match(chatJs, /task-project-head/);
assert.match(chatJs, /groupedTaskListHtml/); assert.match(chatJs, /groupedTaskListHtml/);
assert.match(chatJs, /loadProjectTasks/);
assert.match(chatJs, /working_dir: projectName/);
assert.match(chatJs, /state\.taskHasMore = filtered &&/);
assert.match(chatJs, /task-project-more/);
assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/); assert.match(pageHtml, /\.task-nav-icon \{ width: 15px; height: 15px;/);
assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/); assert.match(pageHtml, /\.task-project-head \.task-nav-icon \{ width: 16px; height: 16px;/);
}); });
test("file rows render restrained type-specific svg icons", () => {
assert.match(filesJs, /function fileIconKind/);
assert.match(filesJs, /if \(entry\.is_dir\) return "folder"/);
assert.match(filesJs, /word: new Set/);
assert.match(filesJs, /sheet: new Set/);
assert.match(filesJs, /slide: new Set/);
assert.match(filesJs, /if \(ext === "pdf"\) return "pdf"/);
assert.match(filesJs, /image: new Set/);
assert.match(filesJs, /archive: new Set/);
assert.match(filesJs, /code: new Set/);
assert.match(filesJs, /file-type-icon type-\$\{kind\}/);
assert.match(pageHtml, /\.file-type-icon svg \{ width: 16px; height: 16px;/);
assert.doesNotMatch(pageHtml, /\.ico-file::before/);
});
test("current directory supports search, sort, and modification time", () => { test("current directory supports search, sort, and modification time", () => {
assert.match(pageHtml, /id="file-search"/); assert.match(pageHtml, /id="file-search"/);
assert.match(pageHtml, /id="file-sort"/); assert.match(pageHtml, /id="file-sort"/);

View File

@ -699,6 +699,13 @@
.task-project-count { flex: none; min-width: 20px; color: var(--muted); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; } .task-project-count { flex: none; min-width: 20px; color: var(--muted); font-size: 11px; text-align: right; font-variant-numeric: tabular-nums; }
.task-project-items { display: none; padding: 1px 0 5px; } .task-project-items { display: none; padding: 1px 0 5px; }
.task-project.open .task-project-items { display: block; } .task-project.open .task-project-items { display: block; }
.task-project-state, .task-project-more, .task-project-retry {
min-height: 28px; margin: 2px 10px 2px 38px; padding: 4px 8px; color: var(--muted); font-size: 11px;
}
.task-project-more, .task-project-retry { width: calc(100% - 48px); border: 0; background: transparent;
text-align: left; cursor: pointer; border-radius: 4px; }
.task-project-more:hover, .task-project-retry:hover { background: var(--hover); color: var(--text); }
.task-project-retry { color: var(--danger); }
.task-filter-results { padding: 5px 0 10px; } .task-filter-results { padding: 5px 0 10px; }
.task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px; .task-row .desc { font-weight: 500; color: var(--text); margin-bottom: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@ -1449,8 +1456,17 @@
.file-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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 .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); } .file-type-icon { width: 24px; height: 24px; flex: none; display: inline-flex; align-items: center; justify-content: center;
.ico-file::before { content: "· "; color: var(--muted); } border-radius: 5px; color: #747b84; background: #f4f5f6; }
.file-type-icon svg { width: 16px; height: 16px; display: block; }
.file-type-icon.type-folder { color: #b67812; background: #fff7e5; }
.file-type-icon.type-word { color: #3b6fb6; background: #edf4ff; }
.file-type-icon.type-sheet { color: #278257; background: #ebf7f0; }
.file-type-icon.type-slide { color: #b8642d; background: #fff1e8; }
.file-type-icon.type-pdf { color: #bd3f38; background: #fff0ef; }
.file-type-icon.type-image { color: #7c5bb3; background: #f4effc; }
.file-type-icon.type-archive { color: #80663d; background: #f6f1e9; }
.file-type-icon.type-code { color: #4c708b; background: #edf4f7; }
/* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */ /* 拖拽上传 overlay:hover 整个 pane-right 时铺一层提示 */
#pane-right { position: relative; } #pane-right { position: relative; }

View File

@ -97,6 +97,10 @@ export async function loadModels() {
// loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发 // loadTaskList:默认 reset(filters/refresh/写操作后),append=true 由 sentinel observer 触发
// 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应 // 并发模型:append 受 taskLoading 互斥(避免观察器重复触发);reset 永远抢占,用 seq 丢弃过期响应
let _taskLoadSeq = 0; let _taskLoadSeq = 0;
let _globalTaskResults = [];
const _projectTaskPages = new Map();
const _projectTaskLoading = new Set();
export async function loadTaskList({ append = false } = {}) { export async function loadTaskList({ append = false } = {}) {
if (append && (state.taskLoading || !state.taskHasMore)) return; if (append && (state.taskLoading || !state.taskHasMore)) return;
const mySeq = ++_taskLoadSeq; const mySeq = ++_taskLoadSeq;
@ -123,7 +127,11 @@ export async function loadTaskList({ append = false } = {}) {
const results = data.results || []; const results = data.results || [];
if (!append) state.taskLoaded = 0; if (!append) state.taskLoaded = 0;
state.taskLoaded += results.length; state.taskLoaded += results.length;
state.taskHasMore = state.taskLoaded < state.taskTotal; // 常态项目导航只需首页的最近对话;各项目在展开时独立分页。
// 全局 sentinel 只服务搜索/筛选结果,避免折叠项目让它提前入视口而连续拉全量历史。
const filtered = isTaskListFiltered();
state.taskHasMore = filtered && state.taskLoaded < state.taskTotal;
if (!append && !filtered) _projectTaskPages.clear();
renderTaskList(results, append); renderTaskList(results, append);
renderTaskCount(); renderTaskCount();
subscribeRunningRows(results); subscribeRunningRows(results);
@ -206,12 +214,12 @@ function syncTaskRowRunIndicator(tid) {
} }
} }
const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v1"; const TASK_PROJECTS_EXPANDED_KEY = "zcbot-task-projects-expanded-v2";
function taskProject(t) { function taskProject(t) {
const path = t.working_dir || ""; const path = t.working_dir || "";
const name = path.split("/").filter(Boolean).pop() || "未分组对话"; const name = path.split("/").filter(Boolean).pop() || "未分组对话";
return { key: path || "__ungrouped__", name }; return { key: path ? name : "__ungrouped__", name };
} }
function taskNavIcon(kind) { function taskNavIcon(kind) {
@ -272,13 +280,53 @@ function expandedTaskProjects() {
} }
} }
function groupedTaskListHtml(tasks) { function taskProjectGroups(tasks) {
const groups = new Map(); const loadedGroups = new Map();
for (const task of tasks) { for (const task of tasks) {
const project = taskProject(task); const project = taskProject(task);
if (!groups.has(project.key)) groups.set(project.key, { ...project, tasks: [] }); if (!loadedGroups.has(project.key)) loadedGroups.set(project.key, []);
groups.get(project.key).tasks.push(task); loadedGroups.get(project.key).push(task);
} }
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,
count: Number(folder.n_tasks) || 0,
seedTasks: loadedGroups.get(folder.name) || [],
});
seen.add(folder.name);
}
// 容错:磁盘目录索引暂时拉不到时,已返回的 task 仍然可访问。
for (const [key, groupTasks] of loadedGroups) {
if (!seen.has(key)) groups.push({
key,
name: key === "__ungrouped__" ? "未分组对话" : key,
count: groupTasks.length,
seedTasks: groupTasks,
});
}
return groups;
}
function projectItemsHtml(group) {
const page = _projectTaskPages.get(group.key);
const tasks = page ? page.tasks : group.seedTasks;
const rows = tasks.map((t) => taskRowHtml(t, { compact: true, nested: true })).join("");
const status = page?.error
? `<button class="task-project-retry" type="button" data-project-name="${escapeHtml(group.key)}">加载失败,点击重试</button>`
: (_projectTaskLoading.has(group.key)
? `<div class="task-project-state">加载中…</div>`
: (page?.hasMore
? `<button class="task-project-more" type="button" data-project-name="${escapeHtml(group.key)}">加载更多</button>`
: ""));
return rows || status ? `${rows}${status}` : `<div class="task-project-state">暂无对话</div>`;
}
function groupedTaskListHtml(tasks) {
const groups = taskProjectGroups(tasks);
const expanded = expandedTaskProjects(); const expanded = expandedTaskProjects();
const activeProject = state.taskId && state.tasksById[state.taskId] const activeProject = state.taskId && state.tasksById[state.taskId]
? taskProject(state.tasksById[state.taskId]).key : ""; ? taskProject(state.tasksById[state.taskId]).key : "";
@ -288,21 +336,94 @@ function groupedTaskListHtml(tasks) {
<div class="task-nav-section-title">${taskNavIcon("recent")}<span>最近对话</span></div> <div class="task-nav-section-title">${taskNavIcon("recent")}<span>最近对话</span></div>
${recent.map((t) => taskRowHtml(t, { compact: true })).join("")} ${recent.map((t) => taskRowHtml(t, { compact: true })).join("")}
</section>` : ""; </section>` : "";
const projectsHtml = Array.from(groups.values()).map((group, index) => { const projectsHtml = groups.map((group, index) => {
const open = expanded === null ? (group.key === activeProject || index === 0) : expanded.has(group.key); const open = expanded === null ? (group.key === activeProject || index === 0) : expanded.has(group.key);
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)}"> return `<section class="task-project${open ? " open" : ""}" data-project-key="${escapeHtml(group.key)}">
<button class="task-project-head" type="button" aria-expanded="${open}"> <button class="task-project-head" type="button" aria-expanded="${open}">
<span class="task-project-chevron" aria-hidden="true"></span> <span class="task-project-chevron" aria-hidden="true"></span>
${taskNavIcon("folder")} ${taskNavIcon("folder")}
<span class="task-project-name" title="${escapeHtml(group.key === "__ungrouped__" ? group.name : group.key)}">${escapeHtml(group.name)}</span> <span class="task-project-name" title="${escapeHtml(group.name)}">${escapeHtml(group.name)}</span>
<span class="task-project-count">${group.tasks.length}</span> <span class="task-project-count">${exactCount}</span>
</button> </button>
<div class="task-project-items">${group.tasks.map((t) => taskRowHtml(t, { compact: true, nested: true })).join("")}</div> <div class="task-project-items">${projectItemsHtml(group)}</div>
</section>`; </section>`;
}).join(""); }).join("");
return `${recentHtml}<div class="task-projects">${projectsHtml}</div>`; return `${recentHtml}<div class="task-projects">${projectsHtml}</div>`;
} }
async function loadProjectTasks(projectName, { append = false } = {}) {
if (!projectName || _projectTaskLoading.has(projectName)) return;
const current = _projectTaskPages.get(projectName);
if (append && current && !current.hasMore) return;
const nextPage = append && current ? current.page + 1 : 1;
_projectTaskLoading.add(projectName);
_projectTaskPages.set(projectName, {
tasks: append && current ? current.tasks : [],
page: append && current ? current.page : 0,
count: current?.count || 0,
hasMore: current?.hasMore ?? true,
error: "",
});
renderTaskNavigation();
try {
const params = new URLSearchParams({
page: String(nextPage),
page_size: String(state.taskPageSize),
working_dir: projectName,
});
const data = await api("GET", "/v1/tasks?" + params.toString());
const results = data.results || [];
const previous = append && current ? current.tasks : [];
const merged = [...previous];
const known = new Set(previous.map((task) => task.task_id));
for (const task of results) {
state.tasksById[task.task_id] = task;
if (!known.has(task.task_id)) merged.push(task);
}
const count = Number(data.count) || 0;
_projectTaskPages.set(projectName, {
tasks: merged,
page: data.page || nextPage,
count,
hasMore: merged.length < count,
error: "",
});
subscribeRunningRows(results);
} catch (error) {
if (error.status === 401) { logout(); return; }
_projectTaskPages.set(projectName, {
tasks: append && current ? current.tasks : [],
page: append && current ? current.page : 0,
count: current?.count || 0,
hasMore: true,
error: error.message || "加载失败",
});
} finally {
_projectTaskLoading.delete(projectName);
renderTaskNavigation();
}
}
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>`;
} else {
listEl.innerHTML = groupedTaskListHtml(_globalTaskResults);
}
bindTaskListInteractions(listEl);
}
export function refreshTaskProjectIndex() {
if (!isTaskListFiltered() && _globalTaskResults.length) renderTaskNavigation();
}
function bindTaskListInteractions(root) { function bindTaskListInteractions(root) {
root.querySelectorAll(".task-row").forEach((el) => { root.querySelectorAll(".task-row").forEach((el) => {
el.onclick = (e) => { el.onclick = (e) => {
@ -325,31 +446,51 @@ function bindTaskListInteractions(root) {
button.onclick = () => { button.onclick = () => {
const section = button.closest(".task-project"); const section = button.closest(".task-project");
const next = !section.classList.contains("open"); const next = !section.classList.contains("open");
const currentOpen = new Set(Array.from(root.querySelectorAll(".task-project.open"))
.map((item) => item.dataset.projectKey));
section.classList.toggle("open", next); section.classList.toggle("open", next);
button.setAttribute("aria-expanded", String(next)); button.setAttribute("aria-expanded", String(next));
const expanded = expandedTaskProjects() || new Set(); const expanded = expandedTaskProjects() || currentOpen;
if (next) expanded.add(section.dataset.projectKey); if (next) expanded.add(section.dataset.projectKey);
else expanded.delete(section.dataset.projectKey); else expanded.delete(section.dataset.projectKey);
localStorage.setItem(TASK_PROJECTS_EXPANDED_KEY, JSON.stringify(Array.from(expanded))); localStorage.setItem(TASK_PROJECTS_EXPANDED_KEY, JSON.stringify(Array.from(expanded)));
if (next && section.dataset.projectKey !== "__ungrouped__" && !_projectTaskPages.has(section.dataset.projectKey)) {
loadProjectTasks(section.dataset.projectKey);
}
}; };
}); });
root.querySelectorAll(".task-project-more, .task-project-retry").forEach((button) => {
button.onclick = (event) => {
event.stopPropagation();
loadProjectTasks(button.dataset.projectName, { append: button.classList.contains("task-project-more") });
};
});
if (!isTaskListFiltered()) {
for (const section of root.querySelectorAll(".task-project.open")) {
if (section.dataset.projectKey !== "__ungrouped__" && !_projectTaskPages.has(section.dataset.projectKey)) {
queueMicrotask(() => loadProjectTasks(section.dataset.projectKey));
}
}
}
} }
function renderTaskList(tasks, append = false) { function renderTaskList(tasks, append = false) {
if (!append) state.tasksById = {}; if (!append) {
for (const t of tasks) state.tasksById[t.task_id] = t; state.tasksById = {};
const listEl = $("task-list"); _globalTaskResults = [];
const loadedTasks = Object.values(state.tasksById);
if (!loadedTasks.length) {
listEl.innerHTML = `<div class="empty">(暂无对话)</div>`;
return;
} }
if (isTaskListFiltered()) { const positions = new Map(_globalTaskResults.map((task, index) => [task.task_id, index]));
listEl.innerHTML = `<div class="task-filter-results"><div class="task-nav-section-title">筛选结果</div>${loadedTasks.map((t) => taskRowHtml(t)).join("")}</div>`; for (const task of tasks) {
state.tasksById[task.task_id] = task;
const position = positions.get(task.task_id);
if (position === undefined) {
positions.set(task.task_id, _globalTaskResults.length);
_globalTaskResults.push(task);
} else { } else {
listEl.innerHTML = groupedTaskListHtml(loadedTasks); _globalTaskResults[position] = task;
} }
bindTaskListInteractions(listEl); }
renderTaskNavigation();
} }
// 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互 // 渠道镜像卡片(微信 / 企业微信):左栏「新建任务」下方固定入口,收拢所有渠道交互

View File

@ -312,6 +312,44 @@ function fileMtime(value) {
}); });
} }
const FILE_ICON_EXTENSIONS = {
word: new Set(["doc", "docx", "odt", "rtf"]),
sheet: new Set(["xls", "xlsx", "csv", "tsv"]),
slide: new Set(["ppt", "pptx", "odp"]),
image: new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tif", "tiff"]),
archive: new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]),
code: new Set(["html", "htm", "css", "js", "mjs", "ts", "tsx", "jsx", "json", "xml", "yaml", "yml", "py", "r", "sql", "ipynb"]),
text: new Set(["md", "txt", "log"]),
};
function fileIconKind(entry) {
if (entry.is_dir) return "folder";
const dot = entry.name.lastIndexOf(".");
const ext = dot > -1 ? entry.name.slice(dot + 1).toLocaleLowerCase("en-US") : "";
if (ext === "pdf") return "pdf";
for (const [kind, extensions] of Object.entries(FILE_ICON_EXTENSIONS)) {
if (extensions.has(ext)) return kind;
}
return "file";
}
function fileIconHtml(entry) {
const kind = fileIconKind(entry);
const paths = {
folder: `<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"/>`,
image: `<rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="9" cy="9" r="1.5"/><path d="m5.5 17 4-4 3 3 2.5-2.5 3.5 3.5"/>`,
sheet: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4M9 11h6M9 15h6M12 11v7M9 18h6"/>`,
slide: `<path d="M5 3.5h14v12H5zM9 20h6M12 15.5V20"/><path d="M8 7.5h8M8 10.5h5"/>`,
archive: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4M10 5h2M10 8h2M10 11h2M9.5 14h3v3h-3z"/>`,
code: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4M11 11l-2 2 2 2M14 11l2 2-2 2"/>`,
pdf: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4M9 16c2-4 3-5 4-5 1.5 0 1.3 4 3 4-2.5 0-5 .2-7 1z"/>`,
word: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4M9 11h6M9 14h6M9 17h4"/>`,
text: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4M9 11h6M9 14h6M9 17h5"/>`,
file: `<path d="M6 2.75h8l4 4V21H6z"/><path d="M14 2.75V7h4"/>`,
};
return `<span class="file-type-icon type-${kind}" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${paths[kind]}</svg></span>`;
}
function renderFiles(data) { function renderFiles(data) {
$("file-search").value = state.filesQuery; $("file-search").value = state.filesQuery;
$("file-sort").value = state.filesSort; $("file-sort").value = state.filesSort;
@ -369,12 +407,12 @@ function renderFiles(data) {
state.entriesByRel = {}; state.entriesByRel = {};
for (const e of visibleEntries) state.entriesByRel[e.rel] = e; for (const e of visibleEntries) state.entriesByRel[e.rel] = e;
$("file-list").innerHTML = visibleEntries.map((e) => { $("file-list").innerHTML = visibleEntries.map((e) => {
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)}">
${fileIconHtml(e)}
<span class="file-main"> <span class="file-main">
<span class="${cls} name" data-rel="${escapeHtml(e.rel)}" data-isdir="${e.is_dir}" title="${escapeHtml(fullTitle)}">${escapeHtml(e.name)}</span> <span class="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 class="mtime">修改 ${escapeHtml(fileMtime(e.mtime))}</span>
</span> </span>
<span class="size">${humanSize(e.size)}</span> <span class="size">${humanSize(e.size)}</span>

View File

@ -7,7 +7,7 @@ import { state } from "./state.js";
import { api } from "./api.js"; import { api } from "./api.js";
import { escapeHtml } from "./format.js"; import { escapeHtml } from "./format.js";
import { logout } from "./auth.js"; import { logout } from "./auth.js";
import { loadModels, loadTaskList, selectTask } from "./chat.js"; import { loadModels, loadTaskList, refreshTaskProjectIndex, selectTask } from "./chat.js";
// ───── new task ───── // ───── new task ─────
const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir"; const LS_RECENT_WORKING_DIR = "zcbot.recent-working-dir";
@ -78,6 +78,7 @@ export async function loadFolderSuggestions() {
} }
populateFolderSelects(); populateFolderSelects();
renderCustomFolderOptions(); renderCustomFolderOptions();
refreshTaskProjectIndex();
} }
// 灌顶部 filter-wd自定义创建弹框使用与快速新对话一致的组合框。 // 灌顶部 filter-wd自定义创建弹框使用与快速新对话一致的组合框。