fix(dashboard): stabilize sidebar customization state (#5405) (#5670)

- use stable sidebar list keys to avoid vnode reuse drift

- sanitize persisted opened groups against current sidebar menu

- guard non-array customization keys from localStorage

Co-authored-by: Gargantua <22532097@zju.edu.cn>
This commit is contained in:
Gargantua
2026-03-03 15:12:15 +08:00
committed by GitHub
parent 866e546b59
commit 82e7502f74
3 changed files with 103 additions and 14 deletions
@@ -38,7 +38,7 @@ const isItemActive = computed(() => {
</template>
<!-- children -->
<template v-for="(child, index) in item.children" :key="index">
<template v-for="(child, index) in item.children" :key="child.title || child.to || `child-${index}`">
<NavItem :item="child" :level="(level || 0) + 1" />
</template>
</v-list-group>
@@ -10,26 +10,60 @@ import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
const { t, locale } = useI18n();
const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
function collectGroupValues(items, values = new Set()) {
items.forEach((item) => {
if (item?.children && item.title) {
values.add(item.title);
collectGroupValues(item.children, values);
}
});
return values;
}
function sanitizeOpenedItems(items, menuItems) {
if (!Array.isArray(items)) {
return [];
}
const groupValues = collectGroupValues(menuItems);
return items.filter((item) => typeof item === 'string' && groupValues.has(item));
}
function getInitialOpenedItems(menuItems) {
try {
const stored = JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]');
return sanitizeOpenedItems(stored, menuItems);
} catch {
return [];
}
}
const sidebarMenu = shallowRef(applySidebarCustomization(sidebarItems));
// 侧边栏分组展开状态持久化
const openedItems = ref(JSON.parse(localStorage.getItem('sidebar_openedItems') || '[]'));
watch(openedItems, (val) => localStorage.setItem('sidebar_openedItems', JSON.stringify(val)), { deep: true });
const openedItems = ref(getInitialOpenedItems(sidebarMenu.value));
watch(openedItems, (val) => {
localStorage.setItem('sidebar_openedItems', JSON.stringify(sanitizeOpenedItems(val, sidebarMenu.value)));
}, { deep: true });
function refreshSidebarMenu() {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
openedItems.value = sanitizeOpenedItems(openedItems.value, sidebarMenu.value);
}
// Apply customization on mount and listen for storage changes
const handleStorageChange = (e) => {
if (e.key === 'astrbot_sidebar_customization') {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
refreshSidebarMenu();
}
};
const handleCustomEvent = () => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
refreshSidebarMenu();
};
onMounted(() => {
sidebarMenu.value = applySidebarCustomization(sidebarItems);
window.addEventListener('storage', handleStorageChange);
window.addEventListener('sidebar-customization-changed', handleCustomEvent);
});
@@ -255,7 +289,7 @@ function openChangelogDialog() {
>
<div class="sidebar-container">
<v-list class="pa-4 listitem flex-grow-1" v-model:opened="openedItems" :open-strategy="'multiple'">
<template v-for="(item, i) in sidebarMenu" :key="i">
<template v-for="(item, i) in sidebarMenu" :key="item.title || item.to || `sidebar-item-${i}`">
<NavItem :item="item" class="leftPadding" />
</template>
</v-list>
+60 -5
View File
@@ -52,6 +52,21 @@ export function clearSidebarCustomization() {
export function resolveSidebarItems(defaultItems, customization, options = {}) {
const { cloneItems = false, assembleMoreGroup = false } = options;
const normalizeKeys = (keys = []) => {
const list = Array.isArray(keys) ? keys : [];
const deduped = [];
const seen = new Set();
list.forEach((key) => {
if (typeof key !== 'string') return;
if (seen.has(key)) return;
seen.add(key);
deduped.push(key);
});
return deduped;
};
const all = new Map();
const defaultMain = [];
const defaultMore = [];
@@ -70,9 +85,23 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
});
const hasCustomization = Boolean(customization);
const mainKeys = hasCustomization ? customization.mainItems || [] : defaultMain;
const moreKeys = hasCustomization ? customization.moreItems || [] : defaultMore;
const used = hasCustomization ? new Set([...mainKeys, ...moreKeys]) : new Set(defaultMain.concat(defaultMore));
let mainKeys = hasCustomization ? normalizeKeys(customization.mainItems || []) : [...defaultMain];
let moreKeys = hasCustomization ? normalizeKeys(customization.moreItems || []) : [...defaultMore];
if (hasCustomization) {
mainKeys = mainKeys.filter(title => all.has(title));
moreKeys = moreKeys.filter(title => all.has(title));
}
if (hasCustomization) {
// 如果同一项同时出现在主区与更多区,主区优先。
const mainSet = new Set(mainKeys);
moreKeys = moreKeys.filter(title => !mainSet.has(title));
}
const used = hasCustomization
? new Set([...mainKeys, ...moreKeys])
: new Set(defaultMain.concat(defaultMore));
const mainItems = mainKeys
.map(title => all.get(title))
@@ -119,7 +148,13 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
}
}
return { mainItems, moreItems, merged };
return {
mainItems,
moreItems,
merged,
normalizedMainKeys: [...mainKeys],
normalizedMoreKeys: [...moreKeys]
};
}
/**
@@ -129,9 +164,29 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
*/
export function applySidebarCustomization(defaultItems) {
const customization = getSidebarCustomization();
const { merged } = resolveSidebarItems(defaultItems, customization, {
const {
merged,
normalizedMainKeys,
normalizedMoreKeys
} = resolveSidebarItems(defaultItems, customization, {
cloneItems: true,
assembleMoreGroup: true
});
if (customization) {
const rawMainKeys = Array.isArray(customization.mainItems) ? customization.mainItems : [];
const rawMoreKeys = Array.isArray(customization.moreItems) ? customization.moreItems : [];
const hasChanged =
JSON.stringify(rawMainKeys) !== JSON.stringify(normalizedMainKeys) ||
JSON.stringify(rawMoreKeys) !== JSON.stringify(normalizedMoreKeys);
if (hasChanged) {
setSidebarCustomization({
mainItems: normalizedMainKeys,
moreItems: normalizedMoreKeys
});
}
}
return merged || defaultItems;
}