Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 74 additions & 20 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 · 实验性' }
Expand All @@ -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 &&
Expand Down Expand Up @@ -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'}`;
Expand All @@ -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');
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand All @@ -1190,34 +1216,58 @@ 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.`;
}

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);
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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();
}
Expand Down
4 changes: 2 additions & 2 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ <h2 id="custom-build-title">选择目标、分支与组件</h2>
</div>
<div class="field-grid">
<label>搜索 / Search
<input id="component-search" type="search" maxlength="80" autocomplete="off" placeholder="套餐、包名、说明、版本">
<input id="component-search" type="search" maxlength="80" autocomplete="off" placeholder="可选:套餐、包名、用途、版本">
</label>
<label>来源 / Source
<select id="component-source" disabled></select>
Expand All @@ -223,7 +223,7 @@ <h2 id="custom-build-title">选择目标、分支与组件</h2>
</div>
<p id="component-package-status" class="component-message package-catalog-status" role="status">正在读取软件包索引… / Loading package index…</p>
<p class="component-message">社区候选库使用锁定但未签名的 Packages.gz 元数据,仅用于展示 provenance;候选包全部不可选,生产构建不会克隆或执行该 feed,也不会下载或安装 dl.openwrt.ai 的 IPK。</p>
<p id="component-result-status" class="component-result-status" aria-live="polite">输入关键词后搜索软件包;精选套餐会直接显示。</p>
<p id="component-result-status" class="component-result-status" aria-live="polite">全部软件包会直接分页显示,并标注用途;搜索仅用于快速筛选。</p>
<p id="component-error" class="component-message" role="alert" hidden></p>
<div class="selected-packages-panel" aria-labelledby="selected-packages-title">
<div class="selected-packages-heading">
Expand Down
24 changes: 24 additions & 0 deletions site/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down
23 changes: 19 additions & 4 deletions tests/test_pages_ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Loading