diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json index 233f28dd5a..0a4a7fe48b 100644 --- a/dashboard/src/i18n/locales/en-US/features/extension.json +++ b/dashboard/src/i18n/locales/en-US/features/extension.json @@ -144,7 +144,8 @@ "updated": "Last Updated", "updateStatus": "Update Status", "ascending": "Ascending", - "descending": "Descending" + "descending": "Descending", + "pinUpdatesOnTop": "Pin Updates on Top" }, "tags": { "danger": "Danger" diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json index b51d0cf783..c085885dbf 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/extension.json +++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json @@ -1,4 +1,4 @@ -{ +{ "title": "Плагины", "subtitle": "Управление и настройка расширений системы", "tabs": { @@ -143,7 +143,8 @@ "updated": "Дате обновления", "updateStatus": "Статусу обновления", "ascending": "По возрастанию", - "descending": "По убыванию" + "descending": "По убыванию", + "pinUpdatesOnTop": "Обновления сверху" }, "tags": { "danger": "Опасно" diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json index 04eaa8bfad..c06f381422 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/extension.json +++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json @@ -147,7 +147,8 @@ "updated": "更新时间", "updateStatus": "更新状态", "ascending": "升序", - "descending": "降序" + "descending": "降序", + "pinUpdatesOnTop": "有更新置顶" }, "tags": { "danger": "危险" diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue index f7cbda8e5b..97b3a56a92 100644 --- a/dashboard/src/views/extension/InstalledPluginsTab.vue +++ b/dashboard/src/views/extension/InstalledPluginsTab.vue @@ -55,6 +55,7 @@ const { installedStatusFilter, installedSortBy, installedSortOrder, + pinUpdatesOnTop, loading_, currentPage, dangerConfirmDialog, @@ -354,6 +355,15 @@ const pinnedPlugins = computed(() => { :show-order="installedSortUsesOrder" @update:order="installedSortOrder = $event" /> + diff --git a/dashboard/src/views/extension/extensionPreferenceStorage.mjs b/dashboard/src/views/extension/extensionPreferenceStorage.mjs new file mode 100644 index 0000000000..31cd90bbef --- /dev/null +++ b/dashboard/src/views/extension/extensionPreferenceStorage.mjs @@ -0,0 +1,84 @@ +export const SHOW_RESERVED_PLUGINS_STORAGE_KEY = "showReservedPlugins"; +export const PLUGIN_LIST_VIEW_MODE_STORAGE_KEY = "pluginListViewMode"; +export const PIN_UPDATES_ON_TOP_STORAGE_KEY = "pinUpdatesOnTop"; + +/** + * Resolve the storage backend for reading preferences. + * Pass `null` to explicitly disable storage access in callers/tests. + */ +const getStorageForRead = (storageOverride) => { + if (storageOverride === null) { + return null; + } + if (storageOverride !== undefined) { + return typeof storageOverride?.getItem === "function" + ? storageOverride + : null; + } + if (typeof window === "undefined") { + return null; + } + try { + const localStorage = window.localStorage ?? null; + return typeof localStorage?.getItem === "function" ? localStorage : null; + } catch { + return null; + } +}; + +/** + * Resolve the storage backend for writing preferences. + * Pass `null` to explicitly disable storage access in callers/tests. + */ +const getStorageForWrite = (storageOverride) => { + if (storageOverride === null) { + return null; + } + if (storageOverride !== undefined) { + return typeof storageOverride?.setItem === "function" + ? storageOverride + : null; + } + if (typeof window === "undefined") { + return null; + } + try { + const localStorage = window.localStorage ?? null; + return typeof localStorage?.setItem === "function" ? localStorage : null; + } catch { + return null; + } +}; + +export const readBooleanPreference = (key, fallback, storage) => { + const targetStorage = getStorageForRead(storage); + if (!targetStorage) { + return fallback; + } + + try { + const saved = targetStorage.getItem(key); + if (saved === "true") { + return true; + } + if (saved === "false") { + return false; + } + return fallback; + } catch { + return fallback; + } +}; + +export const writeBooleanPreference = (key, value, storage) => { + const targetStorage = getStorageForWrite(storage); + if (!targetStorage) { + return; + } + + try { + targetStorage.setItem(key, String(value)); + } catch { + // Ignore restricted storage environments. + } +}; diff --git a/dashboard/src/views/extension/useExtensionPage.js b/dashboard/src/views/extension/useExtensionPage.js index 994be0814b..3ceecc85bc 100644 --- a/dashboard/src/views/extension/useExtensionPage.js +++ b/dashboard/src/views/extension/useExtensionPage.js @@ -14,6 +14,13 @@ import { getValidHashTab, replaceTabRoute, } from "@/utils/hashRouteTabs.mjs"; +import { + PIN_UPDATES_ON_TOP_STORAGE_KEY, + PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, + SHOW_RESERVED_PLUGINS_STORAGE_KEY, + readBooleanPreference, + writeBooleanPreference, +} from "./extensionPreferenceStorage.mjs"; import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useDisplay } from "vuetify"; @@ -124,11 +131,7 @@ export const useExtensionPage = () => { // 从 localStorage 恢复显示系统插件的状态,默认为 false(隐藏) const getInitialShowReserved = () => { - if (typeof window !== "undefined" && window.localStorage) { - const saved = localStorage.getItem("showReservedPlugins"); - return saved === "true"; - } - return false; + return readBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, false); }; const showReserved = ref(getInitialShowReserved()); const snack_message = ref(""); @@ -178,16 +181,20 @@ export const useExtensionPage = () => { // 新增变量支持列表视图 // 从 localStorage 恢复显示模式,默认为 false(卡片视图) const getInitialListViewMode = () => { - if (typeof window !== "undefined" && window.localStorage) { - return localStorage.getItem("pluginListViewMode") === "true"; - } - return false; + return readBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, false); }; const isListView = ref(getInitialListViewMode()); const pluginSearch = ref(""); const installedStatusFilter = ref("all"); const installedSortBy = ref("default"); const installedSortOrder = ref("desc"); + const getInitialPinUpdatesOnTop = () => { + return readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true); + }; + const pinUpdatesOnTop = ref(getInitialPinUpdatesOnTop()); + watch(pinUpdatesOnTop, (val) => { + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, val); + }); const loading_ = ref(false); // 分页相关 @@ -426,6 +433,17 @@ export const useExtensionPage = () => { return Number.isFinite(parsed) ? parsed : null; }; + const compareInstalledFallback = (left, right) => { + const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin); + return nameCompare !== 0 ? nameCompare : left.index - right.index; + }; + + const compareInstalledUpdatePinning = (left, right) => { + const leftHasUpdate = left.plugin?.has_update ? 1 : 0; + const rightHasUpdate = right.plugin?.has_update ? 1 : 0; + return rightHasUpdate - leftHasUpdate; + }; + const sortInstalledPlugins = (plugins) => { return plugins .map((plugin, index) => ({ @@ -434,19 +452,24 @@ export const useExtensionPage = () => { installedAtTimestamp: getInstalledAtTimestamp(plugin), })) .sort((left, right) => { - const fallbackNameCompare = compareInstalledPluginNames( - left.plugin, - right.plugin, - ); - const fallbackResult = - fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index; + if ( + pinUpdatesOnTop.value && + installedSortBy.value !== "update_status" + ) { + // Pinning updates is a primary grouping; the selected sort order still + // applies within the "has update" and "no update" groups below. + const pinCompare = compareInstalledUpdatePinning(left, right); + if (pinCompare !== 0) { + return pinCompare; + } + } if (installedSortBy.value === "install_time") { const leftTimestamp = left.installedAtTimestamp; const rightTimestamp = right.installedAtTimestamp; if (leftTimestamp == null && rightTimestamp == null) { - return fallbackResult; + return compareInstalledFallback(left, right); } if (leftTimestamp == null) { return 1; @@ -459,7 +482,9 @@ export const useExtensionPage = () => { installedSortOrder.value === "desc" ? rightTimestamp - leftTimestamp : leftTimestamp - rightTimestamp; - return timeDiff !== 0 ? timeDiff : fallbackResult; + return timeDiff !== 0 + ? timeDiff + : compareInstalledFallback(left, right); } if (installedSortBy.value === "name") { @@ -469,7 +494,7 @@ export const useExtensionPage = () => { ? -nameCompare : nameCompare; } - return left.index - right.index; + return compareInstalledFallback(left, right); } if (installedSortBy.value === "author") { @@ -482,20 +507,20 @@ export const useExtensionPage = () => { ? -authorCompare : authorCompare; } - return fallbackResult; + return compareInstalledFallback(left, right); } if (installedSortBy.value === "update_status") { - const leftHasUpdate = left.plugin?.has_update ? 1 : 0; - const rightHasUpdate = right.plugin?.has_update ? 1 : 0; const updateDiff = installedSortOrder.value === "desc" - ? rightHasUpdate - leftHasUpdate - : leftHasUpdate - rightHasUpdate; - return updateDiff !== 0 ? updateDiff : fallbackResult; + ? compareInstalledUpdatePinning(left, right) + : compareInstalledUpdatePinning(right, left); + return updateDiff !== 0 + ? updateDiff + : compareInstalledFallback(left, right); } - return fallbackResult; + return compareInstalledFallback(left, right); }) .map((item) => item.plugin); }; @@ -636,9 +661,7 @@ export const useExtensionPage = () => { const toggleShowReserved = () => { showReserved.value = !showReserved.value; // 保存到 localStorage - if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem("showReservedPlugins", showReserved.value.toString()); - } + writeBooleanPreference(SHOW_RESERVED_PLUGINS_STORAGE_KEY, showReserved.value); }; const toast = (message, success) => { @@ -1603,9 +1626,7 @@ export const useExtensionPage = () => { // 监听显示模式变化并保存到 localStorage watch(isListView, (newVal) => { - if (typeof window !== "undefined" && window.localStorage) { - localStorage.setItem("pluginListViewMode", String(newVal)); - } + writeBooleanPreference(PLUGIN_LIST_VIEW_MODE_STORAGE_KEY, newVal); }); watch( @@ -1695,6 +1716,7 @@ export const useExtensionPage = () => { installedStatusFilter, installedSortBy, installedSortOrder, + pinUpdatesOnTop, loading_, currentPage, marketCategoryFilter, diff --git a/dashboard/tests/extensionPreferenceStorage.test.mjs b/dashboard/tests/extensionPreferenceStorage.test.mjs new file mode 100644 index 0000000000..e864f5fae5 --- /dev/null +++ b/dashboard/tests/extensionPreferenceStorage.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + PIN_UPDATES_ON_TOP_STORAGE_KEY, + readBooleanPreference, + writeBooleanPreference, +} from '../src/views/extension/extensionPreferenceStorage.mjs'; + +test("readBooleanPreference returns fallback when storage access throws", () => { + const storage = { + getItem() { + throw new Error("SecurityError"); + }, + }; + + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage), + true, + ); +}); + +test("readBooleanPreference parses stored boolean strings", () => { + const storage = { + getItem(key) { + return key === PIN_UPDATES_ON_TOP_STORAGE_KEY ? "false" : null; + }, + }; + + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage), + false, + ); +}); + +test("readBooleanPreference treats explicit null storage as unavailable", () => { + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null), + true, + ); +}); + +test("readBooleanPreference treats invalid storage overrides as unavailable", () => { + assert.equal( + readBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}), + true, + ); +}); + +test("writeBooleanPreference stores boolean strings and swallows storage errors", () => { + const writes = []; + const storage = { + setItem(key, value) { + writes.push([key, value]); + throw new Error("QuotaExceededError"); + }, + }; + + assert.doesNotThrow(() => + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, storage), + ); + assert.deepEqual(writes, [[PIN_UPDATES_ON_TOP_STORAGE_KEY, "true"]]); +}); + +test("writeBooleanPreference ignores explicit null storage", () => { + assert.doesNotThrow(() => + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, null), + ); +}); + +test("writeBooleanPreference ignores invalid storage overrides", () => { + assert.doesNotThrow(() => + writeBooleanPreference(PIN_UPDATES_ON_TOP_STORAGE_KEY, true, {}), + ); +});