From 6b55f0ef68ae3fdc3448a99a37b7b58f17fdec8a Mon Sep 17 00:00:00 2001 From: "Jayper.Z" Date: Wed, 22 Jul 2026 16:43:13 +0800 Subject: [PATCH] feat(pages): browse all packages without search --- site/app.js | 94 +++++++++++++++++++++++++++++++++--------- site/index.html | 4 +- site/styles.css | 24 +++++++++++ tests/test_pages_ui.js | 23 +++++++++-- 4 files changed, 119 insertions(+), 26 deletions(-) diff --git a/site/app.js b/site/app.js index b77cd86..20e6444 100644 --- a/site/app.js +++ b/site/app.js @@ -636,7 +636,7 @@ const PACKAGE_ARCHITECTURES = { const MAX_CATALOG_ITEMS = 256; const MAX_PACKAGE_SHARDS = 32; const MAX_PACKAGE_RECORDS = 20000; -const MAX_PACKAGE_RESULTS = 100; +const PACKAGE_RESULTS_PAGE_SIZE = 100; const CUSTOM_BUILD_FLAVORS = { official: { id: 'official', label: 'Official · 官方' }, nss: { id: 'nss', label: 'NSS · 实验性' } @@ -652,6 +652,8 @@ let resolvedComponentIds = new Set(); let componentRequestSequence = 0; let packageShardRequestSequence = 0; let componentSearchTimer = null; +let currentPackagePage = 0; +let packageBrowseSignature = ''; function validCatalogText(value, maxLength, allowEmpty = false) { return typeof value === 'string' && value === value.trim() && value.length <= maxLength && @@ -1053,13 +1055,29 @@ function makeBundleOption(component) { source.className = 'component-source-label'; source.textContent = '精选套餐 / Curated bundle'; const detail = document.createElement('small'); - detail.textContent = `${component.description} · ${component.packages.join(', ')}`; - copy.append(name, source, detail); + detail.className = 'component-purpose'; + detail.textContent = `用途 / Purpose:${component.description}`; + const packageList = document.createElement('small'); + packageList.className = 'component-package-list'; + packageList.textContent = `包含 / Includes:${component.packages.join(', ')}`; + copy.append(name, source, detail, packageList); label.append(checkbox, copy); checkbox.addEventListener('change', () => changeComponentSelection(component.id, checkbox.checked)); return label; } +function packagePurposeFallback(record) { + const name = record.package; + if (name.startsWith('luci-app-')) return `为 ${name.slice(9)} 提供 LuCI 网页管理界面。`; + if (name.startsWith('luci-i18n-')) return `为 ${name.slice(10)} 提供 LuCI 界面语言包。`; + if (name.startsWith('luci-proto-')) return `为 ${name.slice(11)} 网络协议提供 LuCI 配置支持。`; + if (name.startsWith('kmod-')) return `提供 ${name.slice(5)} 相关的 Linux 内核模块。`; + if (name.startsWith('python3-')) return `提供 ${name.slice(8)} 的 Python 3 模块或工具。`; + if (name.startsWith('perl-')) return `提供 ${name.slice(5)} 的 Perl 模块或工具。`; + const category = record.category || record.feed || 'OpenWrt'; + return `${category} 分类的软件包;上游目录暂未提供更详细说明。`; +} + function makePackageOption(record) { const label = document.createElement('label'); label.className = `component-option package-option${record.selectable ? '' : ' package-option-blocked'}`; @@ -1080,7 +1098,8 @@ function makePackageOption(record) { meta.className = 'package-meta'; meta.textContent = `${record.version} · ${record.arch} · ${record.feed} · ${record.source === 'kiddin9' ? '社区候选 / kiddin9(未审核)' : '官方'} · ${formatInstalledSize(record.installed_size)}`; const detail = document.createElement('small'); - detail.textContent = record.description || '暂无说明 / No description'; + detail.className = 'component-purpose'; + detail.textContent = `用途 / Purpose:${record.description || packagePurposeFallback(record)}`; copy.append(titleRow, meta, detail); if (!record.selectable) { const blocked = document.createElement('small'); @@ -1134,12 +1153,17 @@ function renderComponentChoices() { list.replaceChildren(); return; } - const { targetId } = selectedTargetAndFlavor(); + const { targetId, flavorId } = selectedTargetAndFlavor(); const query = document.querySelector('#component-search').value.trim().toLocaleLowerCase('zh-CN'); const categoryFilter = document.querySelector('#component-category').value; const sourceFilter = document.querySelector('#component-source').value; const riskFilter = document.querySelector('#component-risk').value; const feedFilter = document.querySelector('#component-feed').value; + const browseSignature = [targetId, flavorId, query, categoryFilter, sourceFilter, riskFilter, feedFilter].join('\u0000'); + if (browseSignature !== packageBrowseSignature) { + packageBrowseSignature = browseSignature; + currentPackagePage = 0; + } const fragment = document.createDocumentFragment(); let bundleCount = 0; @@ -1172,15 +1196,17 @@ function renderComponentChoices() { const packageMatches = []; let packageMatchCount = 0; - if (query && sourceFilter !== 'bundles' && currentPackageShard) { + const packagePageStart = currentPackagePage * PACKAGE_RESULTS_PAGE_SIZE; + if (sourceFilter !== 'bundles' && currentPackageShard) { for (const record of currentPackageShard.packages) { if ((!categoryFilter || record.category === categoryFilter) && (!riskFilter || record.risk === riskFilter) && (!feedFilter || record.feed === feedFilter) && (sourceFilter === 'all' || sourceFilter === record.source) && - currentPackageSearchTerms.get(record.id)?.includes(query)) { + (!query || currentPackageSearchTerms.get(record.id)?.includes(query))) { + const matchIndex = packageMatchCount; packageMatchCount += 1; - if (packageMatches.length < MAX_PACKAGE_RESULTS) packageMatches.push(record); + if (matchIndex >= packagePageStart && packageMatches.length < PACKAGE_RESULTS_PAGE_SIZE) packageMatches.push(record); } } if (packageMatchCount) { @@ -1190,24 +1216,50 @@ function renderComponentChoices() { heading.textContent = sourceFilter === 'kiddin9' ? '社区候选库 / kiddin9(未审核)' : sourceFilter === 'official' ? 'Official packages · 官方包' : '软件包 / Packages'; const description = document.createElement('p'); description.className = 'component-category-description'; - description.textContent = packageMatchCount > MAX_PACKAGE_RESULTS - ? `命中 ${packageMatchCount} 条,只显示前 ${MAX_PACKAGE_RESULTS} 条。 / ${packageMatchCount} matches; showing first ${MAX_PACKAGE_RESULTS}.` - : `命中 ${packageMatchCount} 条软件包。 / ${packageMatchCount} package matches.`; + const pageEnd = packagePageStart + packageMatches.length; + description.textContent = `共 ${packageMatchCount} 个软件包,当前显示第 ${packagePageStart + 1}–${pageEnd} 个;每个条目均包含用途说明。 / Showing ${packagePageStart + 1}–${pageEnd} of ${packageMatchCount}.`; group.append(heading, description); for (const record of packageMatches) group.append(makePackageOption(record)); + if (packageMatchCount > PACKAGE_RESULTS_PAGE_SIZE) { + const controls = document.createElement('div'); + controls.className = 'package-pagination'; + const previous = document.createElement('button'); + previous.type = 'button'; + previous.disabled = currentPackagePage === 0; + previous.setAttribute('data-package-page-previous', 'true'); + previous.textContent = '上一页 / Previous'; + previous.addEventListener('click', () => { + if (currentPackagePage === 0) return; + currentPackagePage -= 1; + renderComponentChoices(); + list.scrollTop = 0; + }); + const progress = document.createElement('span'); + const pageCount = Math.ceil(packageMatchCount / PACKAGE_RESULTS_PAGE_SIZE); + progress.textContent = `第 ${currentPackagePage + 1} / ${pageCount} 页`; + const next = document.createElement('button'); + next.type = 'button'; + next.disabled = pageEnd >= packageMatchCount; + next.setAttribute('data-package-page-next', 'true'); + next.textContent = '下一页 / Next'; + next.addEventListener('click', () => { + if (pageEnd >= packageMatchCount) return; + currentPackagePage += 1; + renderComponentChoices(); + list.scrollTop = 0; + }); + controls.append(previous, progress, next); + group.append(controls); + } fragment.append(group); } } const resultStatus = document.querySelector('#component-result-status'); - if (!query && sourceFilter !== 'bundles') { + if (sourceFilter !== 'bundles') { resultStatus.textContent = currentPackageShard - ? '输入关键词后搜索软件包;为避免浏览器卡顿,不会一次渲染完整目录。 / Search to browse packages.' + ? `无需搜索即可浏览:共 ${packageMatchCount} 个软件包,当前显示第 ${packageMatchCount ? packagePageStart + 1 : 0}–${packagePageStart + packageMatches.length} 个;可分页浏览全部。 / Search is optional.` : '官方包目录当前不可用;精选套餐仍可使用。 / Official packages unavailable; curated bundles remain available.'; - } else if (query && sourceFilter !== 'bundles') { - resultStatus.textContent = currentPackageShard - ? `软件包命中 ${packageMatchCount} 条${packageMatchCount > MAX_PACKAGE_RESULTS ? `,只显示前 ${MAX_PACKAGE_RESULTS} 条` : ''}。` - : '官方包目录当前不可用;仅搜索精选套餐。'; } else { resultStatus.textContent = `显示 ${bundleCount} 个精选套餐。 / Showing ${bundleCount} curated bundles.`; } @@ -1215,9 +1267,7 @@ function renderComponentChoices() { if (!fragment.children.length) { const empty = document.createElement('p'); empty.className = 'empty-state'; - empty.textContent = !query && ['official', 'kiddin9'].includes(sourceFilter) - ? '输入关键词搜索软件包;完整目录不会一次性渲染。 / Enter a search term for packages.' - : '没有匹配当前筛选条件的组件。 / No matching components.'; + empty.textContent = '没有匹配当前筛选条件的组件。 / No matching components.'; fragment.append(empty); } list.replaceChildren(fragment); @@ -1404,6 +1454,8 @@ function setupComponentBuilder(catalog) { currentPackageShard = null; currentPackageMap = new Map(); currentPackageSearchTerms = new Map(); + currentPackagePage = 0; + packageBrowseSignature = ''; requestedComponentIds.clear(); resolvedComponentIds.clear(); const targetSelect = document.querySelector('#component-target'); @@ -1439,11 +1491,13 @@ function setupComponentBuilder(catalog) { packageShardRequestSequence += 1; const available = flavorsForTarget(targetSelect.value); populateSelect(flavorSelect, available, available[0].id); + clearOfficialPackageSelections(); await resetComponentsForTarget(); return loadPackageShardForSelection(); } async function updateFlavorSelection() { packageShardRequestSequence += 1; + clearOfficialPackageSelections(); await resetComponentsForTarget(); return loadPackageShardForSelection(); } diff --git a/site/index.html b/site/index.html index d17db2f..694463a 100644 --- a/site/index.html +++ b/site/index.html @@ -204,7 +204,7 @@

选择目标、分支与组件

正在读取软件包索引… / Loading package index…

社区候选库使用锁定但未签名的 Packages.gz 元数据,仅用于展示 provenance;候选包全部不可选,生产构建不会克隆或执行该 feed,也不会下载或安装 dl.openwrt.ai 的 IPK。

-

输入关键词后搜索软件包;精选套餐会直接显示。

+

全部软件包会直接分页显示,并标注用途;搜索仅用于快速筛选。

diff --git a/site/styles.css b/site/styles.css index ac29142..4a391d4 100644 --- a/site/styles.css +++ b/site/styles.css @@ -409,6 +409,29 @@ footer p { margin: 0; text-align: center; } .package-option-blocked { opacity: .72; cursor: not-allowed; } .package-option-blocked:hover { border-color: #303940; } .package-blocked-reason { color: #ffaaa4 !important; } +.component-purpose { color: #d8e0e4 !important; line-height: 1.55; } +.component-package-list { color: var(--muted); overflow-wrap: anywhere; } +.package-pagination { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; + padding: 14px; + border: 1px solid var(--line); + background: #090c0e; + color: var(--muted); + font-size: .72rem; +} +.package-pagination button { + padding: 8px 12px; + border: 1px solid rgba(201, 255, 61, .5); + color: var(--accent); + background: transparent; + cursor: pointer; + font: inherit; +} +.package-pagination button:hover:not(:disabled) { background: rgba(201, 255, 61, .08); } +.package-pagination button:disabled { opacity: .45; cursor: not-allowed; } .component-result-status { margin: 12px 0 0; color: var(--muted); font-size: .74rem; } .package-catalog-status { margin-top: 16px; } .selected-packages-panel { @@ -482,6 +505,7 @@ footer p { margin: 0; text-align: center; } .component-option { padding: 11px; } .package-filter-grid { grid-template-columns: 1fr; } .selected-packages-heading { display: grid; } + .package-pagination { align-items: stretch; flex-direction: column; } } /* Prevent long package lists and request hashes from widening mobile layouts. */ diff --git a/tests/test_pages_ui.js b/tests/test_pages_ui.js index 6b20065..ed87377 100755 --- a/tests/test_pages_ui.js +++ b/tests/test_pages_ui.js @@ -774,15 +774,28 @@ async function assertFrontendBackendHashContract(target, flavor, requestedIds, p assert.equal(document.querySelector('#custom-build-workflow-link').href, 'https://github.com/tifycloud/NexaWrt/actions/workflows/custom-build.yml'); assert.equal(document.querySelector('#custom-build-workflow-link').hidden, false); assert.match(document.querySelector('#component-actions-inputs').textContent, /components=\n/); - assert.equal(packageOptions().length, 0); - assert.match(document.querySelector('#component-result-status').textContent, /输入关键词后搜索软件包/); + assert.equal(packageOptions().length, 100); + assert.match(document.querySelector('#component-result-status').textContent, /无需搜索即可浏览:共 150 个软件包,当前显示第 1–100 个/); assert.match(flattenedText('#component-list'), /精选套餐/); + assert.match(flattenedText('#component-list'), /用途 \/ Purpose:/); + assert.match(flattenedText('#component-list'), /第 1 \/ 2 页/); + const nextPage = flattenChildren(document.querySelector('#component-list')) + .find((item) => item.getAttribute('data-package-page-next') === 'true'); + assert.ok(nextPage); + nextPage.dispatchEvent({ type: 'click' }); + assert.equal(packageOptions().length, 50); + assert.match(document.querySelector('#component-result-status').textContent, /当前显示第 101–150 个/); + assert.match(flattenedText('#component-list'), /第 2 \/ 2 页/); + await changeComponentSelection('pkg-demo-100', true); + assert.equal(packageOptions().length, 50); + assert.match(document.querySelector('#component-result-status').textContent, /当前显示第 101–150 个/); + await changeComponentSelection('pkg-demo-100', false); document.querySelector('#component-search').value = 'demo-search'; renderComponentChoices(); assert.equal(packageOptions().length, 100); - assert.match(document.querySelector('#component-result-status').textContent, /命中 147 条,只显示前 100 条/); - assert.match(flattenedText('#component-list'), /只显示前 100 条/); + assert.match(document.querySelector('#component-result-status').textContent, /共 147 个软件包,当前显示第 1–100 个/); + assert.match(flattenedText('#component-list'), /第 1 \/ 2 页/); document.querySelector('#component-source').value = 'official'; document.querySelector('#component-search').value = 'filter-probe'; @@ -825,6 +838,8 @@ async function assertFrontendBackendHashContract(target, flavor, requestedIds, p document.querySelector('#component-target').value = 'xiaomi_ax9000'; document.querySelector('#component-target').dispatchEvent({ type: 'change' }); + assert.equal(getCurrentPackageRecords().length, 0); + assert.equal(packageOptions().length, 0); await new Promise((resolve) => setTimeout(resolve, 30)); assert.equal(getRequestedComponentIds().includes('pkg-firewall-plus'), false); assert.equal(