From 23ed04458632f298926f919fe0862593d05be651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E2=82=82=E2=82=82H=E2=82=82=E2=82=85NO=E2=82=86?= Date: Thu, 6 Aug 2026 21:33:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=8F=92=E4=BB=B6=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E6=94=AF=E6=8C=81=E6=8C=89=E4=B8=8B=E8=BD=BD=E9=87=8F?= =?UTF-8?q?=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为插件市场现有的前端本地排序补充下载量选项,复用已规范化的 download_count,不改变默认推荐顺序、后端接口和 OpenAPI。 新增稳定且不修改输入的下载量排序工具:支持升降序、未知值双向置底、非法值兜底及相同值原始顺序保留;接入市场排序控件并补齐中、英、俄三语文案。 新增 9 项专项单元测试,覆盖正常排序、零值、缺失与非法值、负数、数字字符串、小数、稳定性和输入不可变。验证通过 45 项 Dashboard Node 测试、额外性质检查、三语 JSON 校验、Vue 类型检查和生产构建。 --- .../locales/en-US/features/extension.json | 1 + .../locales/ru-RU/features/extension.json | 1 + .../locales/zh-CN/features/extension.json | 1 + .../src/views/extension/MarketPluginsTab.vue | 1 + .../src/views/extension/marketPluginSort.mjs | 29 +++++ .../src/views/extension/useExtensionPage.js | 6 +- dashboard/tests/marketPluginSort.test.mjs | 104 ++++++++++++++++++ 7 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/views/extension/marketPluginSort.mjs create mode 100644 dashboard/tests/marketPluginSort.test.mjs diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 8184dcce4f..dbaa9ff1a9 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -185,6 +185,7 @@ "installTime": "Last Modified", "name": "Name", "stars": "Stars", + "downloads": "Downloads", "author": "Author", "updated": "Last Updated", "updateStatus": "Update Status", diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index b4ed54f3e8..4f684d2966 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -184,6 +184,7 @@ "installTime": "Дате установки", "name": "Имени", "stars": "Звездам", + "downloads": "Количеству скачиваний", "author": "Автору", "updated": "Дате обновления", "updateStatus": "Статусу обновления", diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index d7afc3971c..10f2ac06aa 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -185,6 +185,7 @@ "installTime": "最后修改时间", "name": "名称", "stars": "Star数", + "downloads": "下载量", "author": "作者名", "updated": "更新时间", "updateStatus": "更新状态", diff --git a/dashboard/src/views/extension/MarketPluginsTab.vue b/dashboard/src/views/extension/MarketPluginsTab.vue index 0bc8750e80..96eba7c26b 100644 --- a/dashboard/src/views/extension/MarketPluginsTab.vue +++ b/dashboard/src/views/extension/MarketPluginsTab.vue @@ -162,6 +162,7 @@ const currentSourceName = computed(() => { const marketSortItems = computed(() => [ { title: tm("sort.default"), value: "default" }, { title: tm("sort.stars"), value: "stars" }, + { title: tm("sort.downloads"), value: "downloads" }, { title: tm("sort.author"), value: "author" }, { title: tm("sort.updated"), value: "updated" }, ]); diff --git a/dashboard/src/views/extension/marketPluginSort.mjs b/dashboard/src/views/extension/marketPluginSort.mjs new file mode 100644 index 0000000000..fbafd9c17b --- /dev/null +++ b/dashboard/src/views/extension/marketPluginSort.mjs @@ -0,0 +1,29 @@ +// 自定义插件源可能没有 download_count 字段,因此未知下载量在升序和降序中都始终置底 +const normalizeDownloadCount = (value) => { + if (value === undefined || value === null || value === "") { + return undefined; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return undefined; + } + return Math.max(0, Math.trunc(parsed)); +}; + +export const sortMarketPluginsByDownloads = (plugins, order = "desc") => { + const direction = order === "asc" ? 1 : -1; + return (Array.isArray(plugins) ? plugins : []) + .map((plugin, index) => ({ plugin, index })) + .sort((left, right) => { + const countA = normalizeDownloadCount(left.plugin?.download_count); + const countB = normalizeDownloadCount(right.plugin?.download_count); + if (countA === undefined && countB === undefined) { + return left.index - right.index; + } + if (countA === undefined) return 1; + if (countB === undefined) return -1; + const diff = (countA - countB) * direction; + return diff !== 0 ? diff : left.index - right.index; + }) + .map((item) => item.plugin); +}; diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index 7937bb2cb7..3c40637dd9 100644 --- a/dashboard/src/views/extension/useExtensionPage.js +++ b/dashboard/src/views/extension/useExtensionPage.js @@ -14,6 +14,7 @@ import { } from "@/utils/pluginSearch"; import { computed, onMounted, onUnmounted, reactive, ref, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; +import { sortMarketPluginsByDownloads } from "./marketPluginSort.mjs"; const buildFailedPluginItems = (raw) => { return Object.entries(raw || {}).map(([dirName, info]) => { @@ -209,7 +210,7 @@ export const useExtensionPage = (initialTab = "installed") => { const marketSearch = ref(""); const debouncedMarketSearch = ref(""); const refreshingMarket = ref(false); - const sortBy = ref("default"); // default, stars, author, updated + const sortBy = ref("default"); // default, stars, downloads, author, updated const sortOrder = ref("desc"); // desc (降序) or asc (升序) const randomPluginNames = ref([]); const marketCategoryFilter = ref("all"); @@ -392,6 +393,9 @@ export const useExtensionPage = (initialTab = "installed") => { const starsB = b.stars ?? 0; return sortOrder.value === "desc" ? starsB - starsA : starsA - starsB; }); + } else if (sortBy.value === "downloads") { + // 按下载量排序 + plugins = sortMarketPluginsByDownloads(plugins, sortOrder.value); } else if (sortBy.value === "author") { // 按作者名字典序排序 plugins.sort((a, b) => { diff --git a/dashboard/tests/marketPluginSort.test.mjs b/dashboard/tests/marketPluginSort.test.mjs new file mode 100644 index 0000000000..6073e1ca79 --- /dev/null +++ b/dashboard/tests/marketPluginSort.test.mjs @@ -0,0 +1,104 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { sortMarketPluginsByDownloads } from '../src/views/extension/marketPluginSort.mjs'; + +const plugin = (name, downloadCount) => ({ + name, + download_count: downloadCount, +}); + +const names = (plugins) => plugins.map((item) => item.name); + +test('sorts downloads in descending order by default', () => { + const plugins = [plugin('a', 5), plugin('b', 20), plugin('c', 0)]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins)), ['b', 'a', 'c']); + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['b', 'a', 'c']); +}); + +test('sorts downloads in ascending order', () => { + const plugins = [plugin('a', 20), plugin('b', 5), plugin('c', 0)]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['c', 'b', 'a']); +}); + +test('treats zero downloads as a valid value instead of unknown', () => { + const plugins = [plugin('unknown', undefined), plugin('zero', 0), plugin('many', 9)]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['many', 'zero', 'unknown']); + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['zero', 'many', 'unknown']); +}); + +test('places unknown download counts last in both directions', () => { + const unknownValues = [undefined, null, '', 'not-a-number', NaN, Infinity, -Infinity]; + + for (const value of unknownValues) { + const plugins = [plugin('unknown', value), plugin('known', 1)]; + + assert.deepEqual( + names(sortMarketPluginsByDownloads(plugins, 'desc')), + ['known', 'unknown'], + `desc should place ${String(value)} last`, + ); + assert.deepEqual( + names(sortMarketPluginsByDownloads(plugins, 'asc')), + ['known', 'unknown'], + `asc should place ${String(value)} last`, + ); + } +}); + +test('normalizes negative counts to zero', () => { + const plugins = [plugin('negative', -3), plugin('zero', 0), plugin('many', 2)]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['many', 'negative', 'zero']); + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['negative', 'zero', 'many']); +}); + +test('accepts numeric strings and truncates decimals', () => { + const plugins = [plugin('string', '15'), plugin('decimal', 10.9), plugin('plain', 11)]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['string', 'plain', 'decimal']); + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['decimal', 'plain', 'string']); +}); + +test('keeps the original order for equal download counts', () => { + const plugins = [plugin('first', 7), plugin('second', 7), plugin('third', 7), plugin('top', 8)]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['top', 'first', 'second', 'third']); + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['first', 'second', 'third', 'top']); +}); + +test('keeps the original order among multiple unknown values', () => { + const plugins = [ + plugin('unknown-a', undefined), + plugin('known', 3), + plugin('unknown-b', null), + plugin('unknown-c', 'nope'), + ]; + + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), [ + 'known', + 'unknown-a', + 'unknown-b', + 'unknown-c', + ]); + assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), [ + 'known', + 'unknown-a', + 'unknown-b', + 'unknown-c', + ]); +}); + +test('does not mutate the input array or plugin objects', () => { + const plugins = [plugin('a', 1), plugin('b', undefined), plugin('c', 5)]; + const snapshot = plugins.map((item) => ({ ...item })); + + const sorted = sortMarketPluginsByDownloads(plugins, 'desc'); + + assert.notEqual(sorted, plugins); + assert.deepEqual(names(plugins), ['a', 'b', 'c']); + assert.deepEqual(plugins, snapshot); +}); From 5b6804e06665b61c01689e598cdd33e79251b7d0 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:25:49 +0800 Subject: [PATCH 2/2] Delete dashboard/tests/marketPluginSort.test.mjs --- dashboard/tests/marketPluginSort.test.mjs | 104 ---------------------- 1 file changed, 104 deletions(-) delete mode 100644 dashboard/tests/marketPluginSort.test.mjs diff --git a/dashboard/tests/marketPluginSort.test.mjs b/dashboard/tests/marketPluginSort.test.mjs deleted file mode 100644 index 6073e1ca79..0000000000 --- a/dashboard/tests/marketPluginSort.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; - -import { sortMarketPluginsByDownloads } from '../src/views/extension/marketPluginSort.mjs'; - -const plugin = (name, downloadCount) => ({ - name, - download_count: downloadCount, -}); - -const names = (plugins) => plugins.map((item) => item.name); - -test('sorts downloads in descending order by default', () => { - const plugins = [plugin('a', 5), plugin('b', 20), plugin('c', 0)]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins)), ['b', 'a', 'c']); - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['b', 'a', 'c']); -}); - -test('sorts downloads in ascending order', () => { - const plugins = [plugin('a', 20), plugin('b', 5), plugin('c', 0)]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['c', 'b', 'a']); -}); - -test('treats zero downloads as a valid value instead of unknown', () => { - const plugins = [plugin('unknown', undefined), plugin('zero', 0), plugin('many', 9)]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['many', 'zero', 'unknown']); - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['zero', 'many', 'unknown']); -}); - -test('places unknown download counts last in both directions', () => { - const unknownValues = [undefined, null, '', 'not-a-number', NaN, Infinity, -Infinity]; - - for (const value of unknownValues) { - const plugins = [plugin('unknown', value), plugin('known', 1)]; - - assert.deepEqual( - names(sortMarketPluginsByDownloads(plugins, 'desc')), - ['known', 'unknown'], - `desc should place ${String(value)} last`, - ); - assert.deepEqual( - names(sortMarketPluginsByDownloads(plugins, 'asc')), - ['known', 'unknown'], - `asc should place ${String(value)} last`, - ); - } -}); - -test('normalizes negative counts to zero', () => { - const plugins = [plugin('negative', -3), plugin('zero', 0), plugin('many', 2)]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['many', 'negative', 'zero']); - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['negative', 'zero', 'many']); -}); - -test('accepts numeric strings and truncates decimals', () => { - const plugins = [plugin('string', '15'), plugin('decimal', 10.9), plugin('plain', 11)]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['string', 'plain', 'decimal']); - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['decimal', 'plain', 'string']); -}); - -test('keeps the original order for equal download counts', () => { - const plugins = [plugin('first', 7), plugin('second', 7), plugin('third', 7), plugin('top', 8)]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), ['top', 'first', 'second', 'third']); - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), ['first', 'second', 'third', 'top']); -}); - -test('keeps the original order among multiple unknown values', () => { - const plugins = [ - plugin('unknown-a', undefined), - plugin('known', 3), - plugin('unknown-b', null), - plugin('unknown-c', 'nope'), - ]; - - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'desc')), [ - 'known', - 'unknown-a', - 'unknown-b', - 'unknown-c', - ]); - assert.deepEqual(names(sortMarketPluginsByDownloads(plugins, 'asc')), [ - 'known', - 'unknown-a', - 'unknown-b', - 'unknown-c', - ]); -}); - -test('does not mutate the input array or plugin objects', () => { - const plugins = [plugin('a', 1), plugin('b', undefined), plugin('c', 5)]; - const snapshot = plugins.map((item) => ({ ...item })); - - const sorted = sortMarketPluginsByDownloads(plugins, 'desc'); - - assert.notEqual(sorted, plugins); - assert.deepEqual(names(plugins), ['a', 'b', 'c']); - assert.deepEqual(plugins, snapshot); -});