From 3392fefedff3a8acd58830133c0ea33a17514ec7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:23:09 +0800 Subject: [PATCH] Add files via upload --- web/static/css/style.css | 129 ++++++++++++++++++ web/static/i18n/en-US.json | 14 ++ web/static/i18n/zh-CN.json | 14 ++ web/static/js/chat.js | 217 +++++++++++++++++++++++++++--- web/static/js/projects.js | 238 ++++++++++++++++++++++++++++++--- web/static/js/tasks.js | 17 ++- web/static/js/vulnerability.js | 43 +++--- web/templates/index.html | 2 + 8 files changed, 613 insertions(+), 61 deletions(-) diff --git a/web/static/css/style.css b/web/static/css/style.css index 8c760600..0ffa3138 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1658,6 +1658,14 @@ header { gap: 4px; } +.conversations-list-empty { + padding: 12px 10px; + text-align: center; + color: var(--text-muted); + font-size: 0.8125rem; + line-height: 1.5; +} + .conversation-group { display: flex; flex-direction: column; @@ -21998,6 +22006,7 @@ button.chat-files-dropdown-item:hover:not(:disabled) { align-self: stretch; display: flex; flex-direction: column; + height: 100%; background: #ffffff; border: 1px solid var(--border-color, #e2e8f0); border-radius: 14px; @@ -22005,6 +22014,10 @@ button.chat-files-dropdown-item:hover:not(:disabled) { overflow: hidden; min-height: 420px; } +.projects-sidebar-head, +.projects-sidebar-search { + flex-shrink: 0; +} .projects-sidebar-head { display: flex; align-items: center; @@ -22049,6 +22062,122 @@ button.chat-files-dropdown-item:hover:not(:disabled) { flex: 1; overflow-y: auto; padding: 6px 8px 10px; + min-height: 0; +} +.sidebar-list-pagination { + flex-shrink: 0; + border-top: 1px solid #eef2f7; + background: #fafbfc; + padding: 6px 8px; + margin-top: 4px; +} +.sidebar-list-pagination-inner { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 0.75rem; + color: var(--text-secondary, #64748b); +} +.sidebar-list-pagination-inner--compact { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 4px 6px; + flex-wrap: nowrap; +} +.sidebar-list-pagination .pagination-info { + text-align: center; + line-height: 1.3; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-list-pagination-inner--compact .pagination-info { + text-align: left; + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; +} +.sidebar-list-pagination .pagination-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + flex-wrap: nowrap; + flex-shrink: 0; +} +.sidebar-list-pagination .pagination-page { + min-width: 2.25rem; + text-align: center; + font-weight: 500; + font-size: 0.6875rem; + font-variant-numeric: tabular-nums; +} +.sidebar-list-pagination .pagination-page-size { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 3px; + white-space: nowrap; + font-size: 0.6875rem; + flex-shrink: 0; +} +.sidebar-list-pagination .pagination-page-size select { + font-size: 0.6875rem; + padding: 1px 2px; + border-radius: 6px; + border: 1px solid #e2e8f0; + background: #fff; + width: 2.75rem; + min-width: 0; +} +.sidebar-list-pagination .btn-compact { + font-size: 0.75rem; + padding: 4px 8px; + min-height: 0; + line-height: 1.2; +} +.sidebar-list-pagination .btn-icon-pagination { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 1px solid #e2e8f0; + border-radius: 6px; + background: #fff; + color: var(--text-secondary, #64748b); + font-size: 1rem; + line-height: 1; + cursor: pointer; +} +.sidebar-list-pagination .btn-icon-pagination:hover:not(:disabled) { + border-color: #0066ff; + color: #0066ff; +} +.sidebar-list-pagination .btn-icon-pagination:disabled { + opacity: 0.35; + cursor: default; +} +.recent-conversations-section { + display: flex; + flex-direction: column; +} +.conversation-sidebar-pagination { + flex-shrink: 0; + border-top: 1px solid var(--border-color, #e2e8f0); + background: #fff; + padding: 8px 10px; +} +.projects-sidebar-pagination { + margin-top: auto; + flex-shrink: 0; + border-top: 1px solid var(--border-color, #e2e8f0); + background: #fff; + padding: 8px 10px; +} +.conversation-sidebar.collapsed .conversation-sidebar-pagination { + display: none; } .projects-list-item { position: relative; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 118f5497..74368758 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -236,6 +236,13 @@ "newProjectCta": "+ New project", "projectList": "Project list", "searchProjectsPlaceholder": "Search projects…", + "paginationShow": "Show {{start}}-{{end}} of {{total}}", + "paginationRange": "{{start}}-{{end}}/{{total}}", + "paginationTotal": "{{total}} total", + "paginationPage": "{{page}}/{{total}}", + "paginationPerPage": "Per page", + "paginationPrev": "Previous", + "paginationNext": "Next", "selectOrCreateTitle": "Select or create a project", "selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.", "createFirstProject": "Create first project", @@ -415,6 +422,13 @@ "addGroup": "New group", "recentConversations": "Recent conversations", "batchManage": "Batch manage", + "paginationShow": "Show {{start}}-{{end}} of {{total}}", + "paginationRange": "{{start}}-{{end}}/{{total}}", + "paginationTotal": "{{total}} total", + "paginationPage": "{{page}}/{{total}}", + "paginationPerPage": "Per page", + "paginationPrev": "Previous", + "paginationNext": "Next", "attackChain": "Attack chain", "viewAttackChain": "View attack chain", "selectRole": "Select role", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index 7dbf2652..bc7387d7 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -224,6 +224,13 @@ "newProjectCta": "+ 新建项目", "projectList": "项目列表", "searchProjectsPlaceholder": "搜索项目…", + "paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}", + "paginationRange": "{{start}}-{{end}}/{{total}}", + "paginationTotal": "共 {{total}} 条", + "paginationPage": "{{page}}/{{total}}", + "paginationPerPage": "每页", + "paginationPrev": "上一页", + "paginationNext": "下一页", "selectOrCreateTitle": "选择或创建项目", "selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。", "createFirstProject": "创建第一个项目", @@ -403,6 +410,13 @@ "addGroup": "新建分组", "recentConversations": "最近对话", "batchManage": "批量管理", + "paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}", + "paginationRange": "{{start}}-{{end}}/{{total}}", + "paginationTotal": "共 {{total}} 条", + "paginationPage": "{{page}}/{{total}}", + "paginationPerPage": "每页", + "paginationPrev": "上一页", + "paginationNext": "下一页", "attackChain": "攻击链", "viewAttackChain": "查看攻击链", "selectRole": "选择角色", diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 0c9d3f15..1c80d81c 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -2939,6 +2939,8 @@ function createConversationListItem(conversation) { // 处理历史记录搜索 let conversationSearchTimer = null; function handleConversationSearch(query) { + conversationsPagination.page = 1; + conversationsSearchQuery = query || ''; // 防抖处理,避免频繁请求 if (conversationSearchTimer) { clearTimeout(conversationSearchTimer); @@ -2972,6 +2974,8 @@ function clearConversationSearch() { clearBtn.style.display = 'none'; } + conversationsPagination.page = 1; + conversationsSearchQuery = ''; loadConversations(''); } @@ -5608,6 +5612,168 @@ let groupsCache = []; let conversationGroupMappingCache = {}; let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况) let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染 +const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size'; + +function getConversationsPageSize() { + try { + const saved = parseInt(localStorage.getItem(CONVERSATIONS_PAGE_SIZE_KEY), 10); + if ([20, 50, 100].includes(saved)) return saved; + } catch (e) { /* ignore */ } + return 50; +} + +let conversationsPagination = { page: 1, pageSize: getConversationsPageSize(), total: 0 }; +let conversationsSearchQuery = ''; + +function parseListTotalValue(raw, itemsLength) { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; + if (raw != null && raw !== '') { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n >= 0) return n; + } + return itemsLength; +} + +function parseListOffsetValue(raw) { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; + if (raw != null && raw !== '') { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n >= 0) return n; + } + return 0; +} + +function parseConversationsListResponse(data) { + if (Array.isArray(data)) { + return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true }; + } + const items = data.conversations || data.items || []; + const arr = Array.isArray(items) ? items : []; + return { + items: arr, + total: parseListTotalValue(data.total, arr.length), + limit: parseListTotalValue(data.limit, arr.length) || arr.length, + offset: parseListOffsetValue(data.offset), + isLegacyArray: false, + }; +} + +async function resolveConversationsListTotal(params, parsed, pageSize, offset) { + const serverTotal = parsed.total; + if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) { + return serverTotal; + } + if (parsed.items.length < pageSize) { + return Math.max(serverTotal, offset + parsed.items.length); + } + const probe = new URLSearchParams(params); + probe.set('offset', String(offset + pageSize)); + probe.set('limit', '1'); + try { + const res = await apiFetch(`/api/conversations?${probe}`); + if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length); + const probeParsed = parseConversationsListResponse(await res.json()); + if (probeParsed.total > serverTotal) return probeParsed.total; + if (probeParsed.items.length > 0) { + return Math.max(serverTotal, offset + pageSize + 1); + } + } catch (e) { /* ignore */ } + return Math.max(serverTotal, offset + parsed.items.length); +} + +async function fetchAllConversations(searchQuery) { + let all = []; + const pageSize = 200; + let offset = 0; + let total = Infinity; + const search = (searchQuery || '').trim(); + while (all.length < total) { + const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (search) params.set('search', search); + const res = await apiFetch(`/api/conversations?${params}`); + if (!res.ok) throw new Error('load conversations failed'); + const parsed = parseConversationsListResponse(await res.json()); + all = all.concat(parsed.items); + total = parsed.total; + if (!parsed.items.length) break; + offset += parsed.items.length; + } + return all; +} + +function getConversationListEmptyHtml() { + return '
'; +} + +function renderConversationsPagination(visibleCount) { + const el = document.getElementById('conversations-pagination'); + if (!el) return; + const { page, pageSize, total } = conversationsPagination; + const count = typeof visibleCount === 'number' ? visibleCount : (conversationsPagination.visibleCount || 0); + conversationsPagination.visibleCount = count; + + if (count === 0 || total === 0) { + el.innerHTML = ''; + el.hidden = true; + return; + } + + const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1); + const navDisabled = totalPages <= 1; + el.hidden = false; + const start = (page - 1) * pageSize + 1; + const end = Math.min(page * pageSize, total); + const tFn = typeof window.t === 'function' ? window.t.bind(window) : null; + const infoText = tFn + ? tFn('chat.paginationRange', { start, end, total }) + : `${start}-${end}/${total}`; + const pageText = tFn + ? tFn('chat.paginationPage', { page, total: totalPages }) + : `${page}/${totalPages}`; + const perPageLabel = tFn ? tFn('chat.paginationPerPage') : 'Per page'; + const prevLabel = tFn ? tFn('chat.paginationPrev') : 'Prev'; + const nextLabel = tFn ? tFn('chat.paginationNext') : 'Next'; + el.innerHTML = ` + `; +} + +function goConversationsPage(page) { + const totalPages = Math.max(1, Math.ceil((conversationsPagination.total || 0) / conversationsPagination.pageSize) || 1); + const next = Math.min(Math.max(1, page), totalPages); + if (next === conversationsPagination.page) return; + conversationsPagination.page = next; + loadConversationsWithGroups(conversationsSearchQuery); +} + +function changeConversationsPageSize() { + const sel = document.getElementById('conversations-page-size-pagination'); + const newSize = sel ? parseInt(sel.value, 10) : 50; + if (![20, 50, 100].includes(newSize)) return; + try { + localStorage.setItem(CONVERSATIONS_PAGE_SIZE_KEY, String(newSize)); + } catch (e) { /* ignore */ } + conversationsPagination.pageSize = newSize; + conversationsPagination.page = 1; + loadConversationsWithGroups(conversationsSearchQuery); +} + +window.goConversationsPage = goConversationsPage; +window.changeConversationsPageSize = changeConversationsPageSize; // 加载分组列表 async function loadGroups() { @@ -5704,12 +5870,17 @@ async function loadGroups() { async function loadConversationsWithGroups(searchQuery = '') { const loadSeq = ++conversationsListLoadSeq; try { - // 并行加载分组列表、分组映射和对话列表(消除串行等待) - const limit = (searchQuery && searchQuery.trim()) ? 100 : 100; - let url = `/api/conversations?limit=${limit}`; + conversationsSearchQuery = searchQuery || ''; + conversationsPagination.pageSize = getConversationsPageSize(); + const pageSize = conversationsPagination.pageSize; + const offset = (conversationsPagination.page - 1) * pageSize; + const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); if (searchQuery && searchQuery.trim()) { - url += '&search=' + encodeURIComponent(searchQuery.trim()); + convParams.set('search', searchQuery.trim()); + } else { + convParams.set('exclude_grouped', 'true'); } + const url = `/api/conversations?${convParams}`; const [,, response] = await Promise.all([ loadGroups(), loadConversationGroupMapping(), @@ -5726,23 +5897,26 @@ async function loadConversationsWithGroups(searchQuery = '') { const sidebarContent = listContainer.closest('.sidebar-content'); const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0; - const emptyStateHtml = ''; + const emptyStateHtml = getConversationListEmptyHtml(); listContainer.innerHTML = ''; // 如果响应不是200,显示空状态(友好处理,不显示错误) if (!response.ok) { listContainer.innerHTML = emptyStateHtml; if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); + renderConversationsPagination(0); return; } - const conversations = await response.json(); + const data = await response.json(); if (loadSeq !== conversationsListLoadSeq) return; + const parsed = parseConversationsListResponse(data); + conversationsPagination.total = await resolveConversationsListTotal(convParams, parsed, pageSize, offset); // 双重保险:后端或并发情况下若出现重复ID,前端按ID去重 const uniqueConversations = []; const seenConversationIds = new Set(); - (Array.isArray(conversations) ? conversations : []).forEach(conv => { + parsed.items.forEach(conv => { if (!conv || !conv.id || seenConversationIds.has(conv.id)) { return; } @@ -5753,6 +5927,7 @@ async function loadConversationsWithGroups(searchQuery = '') { if (uniqueConversations.length === 0) { listContainer.innerHTML = emptyStateHtml; if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); + renderConversationsPagination(0); return; } @@ -5863,15 +6038,29 @@ async function loadConversationsWithGroups(searchQuery = '') { fragment.appendChild(section); }); + const visibleCount = pinnedConvs.length + Object.values(groups).reduce((n, arr) => n + (arr ? arr.length : 0), 0); + conversationsPagination.visibleCount = visibleCount; + + if (!hasSearchQuery && visibleCount === 0 && parsed.items.length > 0) { + const totalPages = Math.max(1, Math.ceil(parsed.total / pageSize)); + if (conversationsPagination.page < totalPages) { + conversationsPagination.page += 1; + loadConversationsWithGroups(searchQuery); + return; + } + } + if (fragment.children.length === 0) { listContainer.innerHTML = emptyStateHtml; if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); + renderConversationsPagination(0); return; } if (loadSeq !== conversationsListLoadSeq) return; listContainer.appendChild(fragment); updateActiveConversation(); + renderConversationsPagination(visibleCount); // 恢复滚动位置 if (sidebarContent) { @@ -5888,9 +6077,9 @@ async function loadConversationsWithGroups(searchQuery = '') { // 错误时显示空状态,而不是错误提示(更友好的用户体验) const listContainer = document.getElementById('conversations-list'); if (listContainer) { - const emptyStateHtml = ''; - listContainer.innerHTML = emptyStateHtml; + listContainer.innerHTML = getConversationListEmptyHtml(); if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer); + renderConversationsPagination(0); } } } @@ -7004,15 +7193,7 @@ function updateBatchManageTitle(count) { async function showBatchManageModal() { try { - const response = await apiFetch('/api/conversations?limit=1000'); - - // 如果响应不是200,使用空数组(友好处理,不显示错误) - if (!response.ok) { - allConversationsForBatch = []; - } else { - const data = await response.json(); - allConversationsForBatch = Array.isArray(data) ? data : []; - } + allConversationsForBatch = await fetchAllConversations(''); const modal = document.getElementById('batch-manage-modal'); updateBatchManageTitle(allConversationsForBatch.length); diff --git a/web/static/js/projects.js b/web/static/js/projects.js index 83bb8b92..8f48104c 100644 --- a/web/static/js/projects.js +++ b/web/static/js/projects.js @@ -3,6 +3,7 @@ */ let projectsCache = []; let projectsCacheAll = []; +const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size'; let currentProjectId = null; let currentProjectTab = 'facts'; const projectNameById = {}; @@ -167,23 +168,128 @@ function rebuildProjectNameMap(list) { }); } -async function fetchProjectsList(includeArchived) { +function getProjectsListPageSize() { + try { + const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10); + if ([20, 50, 100].includes(saved)) return saved; + } catch (e) { /* ignore */ } + return 50; +} + +let projectsListPagination = { page: 1, pageSize: getProjectsListPageSize(), total: 0 }; +let projectsListSearch = ''; +let _projectsListSearchDebounce = null; + +function parseListTotalValue(raw, itemsLength) { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; + if (raw != null && raw !== '') { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n >= 0) return n; + } + return itemsLength; +} + +function parseListOffsetValue(raw) { + if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; + if (raw != null && raw !== '') { + const n = parseInt(String(raw), 10); + if (Number.isFinite(n) && n >= 0) return n; + } + return 0; +} + +function parseProjectsListResponse(data) { + if (Array.isArray(data)) { + return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true }; + } + const items = data.projects || data.items || []; + const arr = Array.isArray(items) ? items : []; + return { + items: arr, + total: parseListTotalValue(data.total, arr.length), + limit: parseListTotalValue(data.limit, arr.length) || arr.length, + offset: parseListOffsetValue(data.offset), + isLegacyArray: false, + }; +} + +async function resolveProjectsListTotal(params, parsed, pageSize, offset) { + const serverTotal = parsed.total; + // 服务端 total 明确大于当前页末尾 → 直接信任 + if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) { + return serverTotal; + } + // 不足一页 → 已是最后一页 + if (parsed.items.length < pageSize) { + return Math.max(serverTotal, offset + parsed.items.length); + } + // 满页但 total 可能被误算为 items.length → 探测下一页 + const probe = new URLSearchParams(params); + probe.set('offset', String(offset + pageSize)); + probe.set('limit', '1'); + try { + const res = await apiFetch(`/api/projects?${probe}`); + if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length); + const probeParsed = parseProjectsListResponse(await res.json()); + if (probeParsed.total > serverTotal) return probeParsed.total; + if (probeParsed.items.length > 0) { + return Math.max(serverTotal, offset + pageSize + 1); + } + } catch (e) { /* ignore */ } + return Math.max(serverTotal, offset + parsed.items.length); +} + +async function fetchAllProjects(includeArchived) { const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked; - const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200'; - const res = await apiFetch(url); + let all = []; + const pageSize = 200; + let offset = 0; + let total = Infinity; + while (all.length < total) { + const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (!showArchived) params.set('status', 'active'); + const res = await apiFetch(`/api/projects?${params}`); + if (!res.ok) throw new Error(tp('projects.loadProjectsFailed')); + const parsed = parseProjectsListResponse(await res.json()); + all = all.concat(parsed.items); + total = parsed.total; + if (!parsed.items.length) break; + offset += parsed.items.length; + } + return all; +} + +async function fetchProjectsList(includeArchived, opts = {}) { + const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked; + const page = opts.page ?? projectsListPagination.page; + const pageSize = opts.pageSize ?? getProjectsListPageSize(); + const search = opts.search !== undefined ? opts.search : projectsListSearch; + projectsListSearch = search; + const offset = (page - 1) * pageSize; + const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) }); + if (search) params.set('search', search); + if (!showArchived) params.set('status', 'active'); + const res = await apiFetch(`/api/projects?${params}`); if (!res.ok) throw new Error(tp('projects.loadProjectsFailed')); - const data = await res.json(); - projectsCache = Array.isArray(data) ? data : []; - rebuildProjectNameMap(projectsCache); - _projectsListReady = true; + const parsed = parseProjectsListResponse(await res.json()); + const total = await resolveProjectsListTotal(params, parsed, pageSize, offset); + projectsCache = parsed.items; + projectsListPagination = { page, pageSize: pageSize, total }; + rebuildProjectNameMap(projectsCacheAll.length ? projectsCacheAll : projectsCache); return projectsCache; } -/** 对话页等项目选择器:确保列表已拉取(去重并发请求) */ +/** 对话页等项目选择器:确保全量列表已拉取(去重并发请求) */ async function ensureProjectsLoaded(force) { - if (!force && _projectsListReady) return projectsCache; + if (!force && _projectsListReady) return projectsCacheAll; if (!force && _projectsFetchPromise) return _projectsFetchPromise; - _projectsFetchPromise = fetchProjectsList(false) + _projectsFetchPromise = fetchAllProjects(false) + .then((list) => { + projectsCacheAll = list; + rebuildProjectNameMap(projectsCacheAll); + _projectsListReady = true; + return projectsCacheAll; + }) .catch((e) => { _projectsListReady = false; throw e; @@ -204,9 +310,10 @@ async function ensureDefaultActiveProjectForNewChat() { await ensureProjectsLoaded(); const cur = getActiveProjectId(); if (cur && isActiveChatProjectId(cur)) return cur; + const source = projectsCacheAll.length ? projectsCacheAll : projectsCache; const first = - projectsCache.find((p) => p.pinned && p.status !== 'archived') || - projectsCache.find((p) => p.status !== 'archived'); + source.find((p) => p.pinned && p.status !== 'archived') || + source.find((p) => p.status !== 'archived'); if (first) { setActiveProjectId(first.id); return first.id; @@ -238,6 +345,8 @@ async function initProjectsPage() { initProjectsModalEscape(); syncProjectsModalBodyLock(); updateProjectsDetailVisibility(); + projectsListPagination.pageSize = getProjectsListPageSize(); + renderProjectsPagination(); await loadProjectsList(); if (!currentProjectId && projectsCache.length) { const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id'); @@ -250,8 +359,19 @@ async function initProjectsPage() { } async function loadProjectsList() { + _projectsListReady = false; + projectsCacheAll = []; + projectsListPagination.pageSize = getProjectsListPageSize(); await fetchProjectsList(); renderProjectsSidebar(); + renderProjectsPagination(); + try { + projectsCacheAll = await fetchAllProjects(); + rebuildProjectNameMap(projectsCacheAll); + _projectsListReady = true; + } catch (e) { + console.warn(e); + } if (typeof refreshChatProjectSelector === 'function') { refreshChatProjectSelector(); } @@ -277,7 +397,7 @@ function updateProjectsDetailVisibility() { function updateProjectsListCount() { const el = document.getElementById('projects-list-count'); - if (el) el.textContent = String(projectsCache.length); + if (el) el.textContent = String(projectsListPagination.total || projectsCache.length); } /** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */ @@ -385,26 +505,97 @@ function getProjectsListFilter() { } function filterProjectsList() { - renderProjectsSidebar(); + if (_projectsListSearchDebounce) clearTimeout(_projectsListSearchDebounce); + _projectsListSearchDebounce = setTimeout(() => { + _projectsListSearchDebounce = null; + const q = getProjectsListFilter(); + projectsListPagination.page = 1; + fetchProjectsList(undefined, { page: 1, search: q }) + .then(() => { + renderProjectsSidebar(); + renderProjectsPagination(); + }) + .catch((e) => console.warn(e)); + }, 280); +} + +function goProjectsPage(page) { + const totalPages = Math.max(1, Math.ceil((projectsListPagination.total || 0) / projectsListPagination.pageSize) || 1); + const next = Math.min(Math.max(1, page), totalPages); + if (next === projectsListPagination.page) return; + fetchProjectsList(undefined, { page: next }) + .then(() => { + renderProjectsSidebar(); + renderProjectsPagination(); + const listEl = document.getElementById('projects-list'); + if (listEl) listEl.scrollTop = 0; + }) + .catch((e) => console.warn(e)); +} + +function changeProjectsPageSize() { + const sel = document.getElementById('projects-page-size-pagination'); + const newSize = sel ? parseInt(sel.value, 10) : 50; + if (![20, 50, 100].includes(newSize)) return; + try { + localStorage.setItem(PROJECTS_LIST_PAGE_SIZE_KEY, String(newSize)); + } catch (e) { /* ignore */ } + projectsListPagination.pageSize = newSize; + projectsListPagination.page = 1; + fetchProjectsList(undefined, { page: 1, pageSize: newSize }) + .then(() => { + renderProjectsSidebar(); + renderProjectsPagination(); + }) + .catch((e) => console.warn(e)); +} + +function renderProjectsPagination() { + const el = document.getElementById('projects-pagination'); + if (!el) return; + const { page, pageSize, total } = projectsListPagination; + const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1); + const navDisabled = total === 0 || totalPages <= 1; + el.hidden = false; + const start = total === 0 ? 0 : (page - 1) * pageSize + 1; + const end = total === 0 ? 0 : Math.min(page * pageSize, total); + const infoText = tpFmt('projects.paginationRange', `${start}-${end}/${total}`, { start, end, total }); + const pageText = tpFmt('projects.paginationPage', `${page}/${totalPages}`, { page, total: totalPages }); + el.innerHTML = ` + `; } function renderProjectsSidebar() { const el = document.getElementById('projects-list'); if (!el) return; updateProjectsListCount(); - const q = getProjectsListFilter(); - const list = q - ? projectsCache.filter((p) => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q)) - : projectsCache; + const list = projectsCache; if (!projectsCache.length) { el.innerHTML = `