feat: 插件有新版本时置顶显示(可开关) (#7665)

* feat: add pinUpdatesOnTop option to always show plugin updates at top

* fix: add missing pinUpdatesOnTop destructuring in InstalledPluginsTab

* fix: address AI review suggestions - add localStorage persistence and increase switch width

* fix: harden extension preference storage

* fix: refine extension preference sorting

* fix: simplify extension preference sorting

* refactor: simplify extension preference storage access

---------

Co-authored-by: Test User <test@test.com>
Co-authored-by: 邹永赫 <1259085392@qq.com>
This commit is contained in:
千岚之夏
2026-04-21 08:21:07 +08:00
committed by GitHub
parent 76ee4f27dd
commit fb16e12c80
7 changed files with 229 additions and 35 deletions

View File

@@ -144,7 +144,8 @@
"updated": "Last Updated",
"updateStatus": "Update Status",
"ascending": "Ascending",
"descending": "Descending"
"descending": "Descending",
"pinUpdatesOnTop": "Pin Updates on Top"
},
"tags": {
"danger": "Danger"

View File

@@ -1,4 +1,4 @@
{
{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
@@ -143,7 +143,8 @@
"updated": "Дате обновления",
"updateStatus": "Статусу обновления",
"ascending": "По возрастанию",
"descending": "По убыванию"
"descending": "По убыванию",
"pinUpdatesOnTop": "Обновления сверху"
},
"tags": {
"danger": "Опасно"

View File

@@ -147,7 +147,8 @@
"updated": "更新时间",
"updateStatus": "更新状态",
"ascending": "升序",
"descending": "降序"
"descending": "降序",
"pinUpdatesOnTop": "有更新置顶"
},
"tags": {
"danger": "危险"

View File

@@ -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"
/>
<v-switch
v-model="pinUpdatesOnTop"
:label="tm('sort.pinUpdatesOnTop')"
color="primary"
density="compact"
hide-details
class="ml-4"
style="max-width: 200px"
/>
</div>
</div>
</v-col>

View File

@@ -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.
}
};

View File

@@ -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,

View File

@@ -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, {}),
);
});