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
2 changes: 1 addition & 1 deletion dashboard/src/layouts/full/vertical-sidebar/NavItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
50 changes: 42 additions & 8 deletions dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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>
Expand Down
65 changes: 60 additions & 5 deletions dashboard/src/utils/sidebarCustomization.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,21 @@ export function clearSidebarCustomization() {
export function resolveSidebarItems(defaultItems, customization, options = {}) {
const { cloneItems = false, assembleMoreGroup = false } = options;

const normalizeKeys = (keys = []) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): 可以考虑抽取一个可复用的侧边栏自定义配置归一化(normalization)辅助方法,并让 applySidebarCustomization 负责持久化逻辑,这样 resolveSidebarItems 就能专注在根据 key 解析 items 上。

在保留当前行为(归一化 + 持久化)的前提下,通过以下方式降低耦合度与复杂度:

  1. 抽取一个专用的归一化辅助函数
  2. applySidebarCustomization 负责持久化和变更检测
  3. resolveSidebarItems 只专注于根据 key 解析出 items

1. 抽取归一化辅助函数

将归一化流水线从 resolveSidebarItems 中抽离出来,使其可复用:

function normalizeSidebarCustomization(customization, all) {
  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;
  };

  if (!customization) {
    return {
      mainKeys: null, // signal "use defaults"
      moreKeys: null,
    };
  }

  let mainKeys = normalizeKeys(customization.mainItems);
  let moreKeys = normalizeKeys(customization.moreItems);

  // 过滤不存在的 key
  mainKeys = mainKeys.filter(title => all.has(title));
  moreKeys = moreKeys.filter(title => all.has(title));

  // 如果同一项同时出现在主区与更多区,主区优先
  const mainSet = new Set(mainKeys);
  moreKeys = moreKeys.filter(title => !mainSet.has(title));

  return { mainKeys, moreKeys };
}

这样一来,resolveSidebarItems 就可以被简化,也不再需要返回归一化之后的 keys:

export function resolveSidebarItems(defaultItems, customization, options = {}) {
  const { cloneItems = false, assembleMoreGroup = false } = options;

  const all = new Map();
  const defaultMain = [];
  const defaultMore = [];

  defaultItems.forEach(item => {
    if (item.children && item.title === 'core.navigation.groups.more') {
      item.children.forEach(child => {
        all.set(child.title, cloneItems ? { ...child } : child);
        defaultMore.push(child.title);
      });
    } else {
      all.set(item.title, cloneItems ? { ...item } : item);
      defaultMain.push(item.title);
    }
  });

  const { mainKeys, moreKeys } = normalizeSidebarCustomization(customization, all);

  const effectiveMainKeys = mainKeys ?? [...defaultMain];
  const effectiveMoreKeys = moreKeys ?? [...defaultMore];

  const used = new Set([...effectiveMainKeys, ...effectiveMoreKeys]);

  const mainItems = effectiveMainKeys
    .map(title => all.get(title))
    .filter(Boolean);

  // ... existing logic to build moreItems / merged using `used` ...

  return { mainItems, moreItems, merged };
}

2. 在 applySidebarCustomization 内部显式地进行变更检测

使用同一个归一化辅助函数进行持久化,同时用一个小而明确的数组相等性辅助方法替代 JSON.stringify

function areArraysShallowEqual(a, b) {
  if (a === b) return true;
  if (!Array.isArray(a) || !Array.isArray(b)) return false;
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i += 1) {
    if (a[i] !== b[i]) return false;
  }
  return true;
}

export function applySidebarCustomization(defaultItems) {
  const customization = getSidebarCustomization();

  // Build the same `all` map as in resolveSidebarItems or refactor to share it
  const all = new Map();
  const defaultMain = [];
  const defaultMore = [];

  defaultItems.forEach(item => {
    if (item.children && item.title === 'core.navigation.groups.more') {
      item.children.forEach(child => {
        all.set(child.title, child);
        defaultMore.push(child.title);
      });
    } else {
      all.set(item.title, item);
      defaultMain.push(item.title);
    }
  });

  const { mainKeys, moreKeys } = normalizeSidebarCustomization(customization, all);
  const normalizedMainKeys = mainKeys ?? defaultMain;
  const normalizedMoreKeys = moreKeys ?? defaultMore;

  const { merged } = 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 =
      !areArraysShallowEqual(rawMainKeys, normalizedMainKeys) ||
      !areArraysShallowEqual(rawMoreKeys, normalizedMoreKeys);

    if (hasChanged) {
      setSidebarCustomization({
        mainItems: normalizedMainKeys,
        moreItems: normalizedMoreKeys,
      });
    }
  }

  return merged || defaultItems;
}

要点:

  • resolveSidebarItems 不再返回 normalizedMainKeys / normalizedMoreKeys,其职责被限定为将 keys 转换为 items。
  • 归一化逻辑(normalizeSidebarCustomization)是一条单一的、可组合的流水线,被 resolveSidebarItemsapplySidebarCustomization 共同复用。
  • applySidebarCustomization 通过 areArraysShallowEqual 而不是 JSON.stringify 来进行清晰的持久化与变更检测。
Original comment in English

issue (complexity): Consider extracting a reusable sidebar customization normalization helper and letting applySidebarCustomization handle persistence so that resolveSidebarItems stays focused on resolving items from keys.

You can keep the new behavior (normalization + persistence) but reduce coupling/complexity by:

  1. Extracting a dedicated normalization helper
  2. Letting applySidebarCustomization own persistence & change detection
  3. Keeping resolveSidebarItems focused on item resolution

1. Extract a normalization helper

Move the normalization pipeline out of resolveSidebarItems and make it reusable:

function normalizeSidebarCustomization(customization, all) {
  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;
  };

  if (!customization) {
    return {
      mainKeys: null, // signal "use defaults"
      moreKeys: null,
    };
  }

  let mainKeys = normalizeKeys(customization.mainItems);
  let moreKeys = normalizeKeys(customization.moreItems);

  // 过滤不存在的 key
  mainKeys = mainKeys.filter(title => all.has(title));
  moreKeys = moreKeys.filter(title => all.has(title));

  // 如果同一项同时出现在主区与更多区,主区优先
  const mainSet = new Set(mainKeys);
  moreKeys = moreKeys.filter(title => !mainSet.has(title));

  return { mainKeys, moreKeys };
}

Now resolveSidebarItems can be simplified and no longer needs to return normalized keys:

export function resolveSidebarItems(defaultItems, customization, options = {}) {
  const { cloneItems = false, assembleMoreGroup = false } = options;

  const all = new Map();
  const defaultMain = [];
  const defaultMore = [];

  defaultItems.forEach(item => {
    if (item.children && item.title === 'core.navigation.groups.more') {
      item.children.forEach(child => {
        all.set(child.title, cloneItems ? { ...child } : child);
        defaultMore.push(child.title);
      });
    } else {
      all.set(item.title, cloneItems ? { ...item } : item);
      defaultMain.push(item.title);
    }
  });

  const { mainKeys, moreKeys } = normalizeSidebarCustomization(customization, all);

  const effectiveMainKeys = mainKeys ?? [...defaultMain];
  const effectiveMoreKeys = moreKeys ?? [...defaultMore];

  const used = new Set([...effectiveMainKeys, ...effectiveMoreKeys]);

  const mainItems = effectiveMainKeys
    .map(title => all.get(title))
    .filter(Boolean);

  // ... existing logic to build moreItems / merged using `used` ...

  return { mainItems, moreItems, merged };
}

2. Make change detection explicit and local to applySidebarCustomization

Use the same helper for persistence and a small explicit equality helper instead of JSON.stringify:

function areArraysShallowEqual(a, b) {
  if (a === b) return true;
  if (!Array.isArray(a) || !Array.isArray(b)) return false;
  if (a.length !== b.length) return false;
  for (let i = 0; i < a.length; i += 1) {
    if (a[i] !== b[i]) return false;
  }
  return true;
}

export function applySidebarCustomization(defaultItems) {
  const customization = getSidebarCustomization();

  // Build the same `all` map as in resolveSidebarItems or refactor to share it
  const all = new Map();
  const defaultMain = [];
  const defaultMore = [];

  defaultItems.forEach(item => {
    if (item.children && item.title === 'core.navigation.groups.more') {
      item.children.forEach(child => {
        all.set(child.title, child);
        defaultMore.push(child.title);
      });
    } else {
      all.set(item.title, item);
      defaultMain.push(item.title);
    }
  });

  const { mainKeys, moreKeys } = normalizeSidebarCustomization(customization, all);
  const normalizedMainKeys = mainKeys ?? defaultMain;
  const normalizedMoreKeys = moreKeys ?? defaultMore;

  const { merged } = 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 =
      !areArraysShallowEqual(rawMainKeys, normalizedMainKeys) ||
      !areArraysShallowEqual(rawMoreKeys, normalizedMoreKeys);

    if (hasChanged) {
      setSidebarCustomization({
        mainItems: normalizedMainKeys,
        moreItems: normalizedMoreKeys,
      });
    }
  }

  return merged || defaultItems;
}

Key points:

  • resolveSidebarItems no longer returns normalizedMainKeys / normalizedMoreKeys, keeping its responsibility focused on converting keys → items.
  • Normalization (normalizeSidebarCustomization) is a single, composable pipeline used by both resolveSidebarItems and applySidebarCustomization.
  • applySidebarCustomization handles persistence and change detection clearly via areArraysShallowEqual instead of JSON.stringify.

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;
};
Comment on lines +55 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

normalizeKeys 函数的实现可以利用更现代的 JavaScript 特性来简化,使其更简洁易读。直接使用 Set 来去重和 filter 来进行类型检查,可以让代码意图更清晰。

  const normalizeKeys = (keys = []) => {
    if (!Array.isArray(keys)) {
      return [];
    }
    return [...new Set(keys.filter((key) => typeof key === 'string'))];
  };


const all = new Map();
const defaultMain = [];
const defaultMore = [];
Expand All @@ -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))
Expand Down Expand Up @@ -119,7 +148,13 @@ export function resolveSidebarItems(defaultItems, customization, options = {}) {
}
}

return { mainItems, moreItems, merged };
return {
mainItems,
moreItems,
merged,
normalizedMainKeys: [...mainKeys],
normalizedMoreKeys: [...moreKeys]
};
}

/**
Expand All @@ -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);
Comment on lines +177 to +181
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

当前检测变更的逻辑没有正确处理 localStorage 中的 customization.mainItemscustomization.moreItems 不是数组的情况。在这种情况下,rawMainKeys 会被视为空数组,导致 hasChanged 错误地判断为 false,从而无法修复 localStorage 中的脏数据。这里的检查逻辑需要更健壮,以包含对类型的校验。

    const hasChanged =
      !Array.isArray(customization.mainItems) ||
      !Array.isArray(customization.moreItems) ||
      JSON.stringify(customization.mainItems) !== JSON.stringify(normalizedMainKeys) ||
      JSON.stringify(customization.moreItems) !== JSON.stringify(normalizedMoreKeys);


if (hasChanged) {
setSidebarCustomization({
mainItems: normalizedMainKeys,
moreItems: normalizedMoreKeys
});
}
}

return merged || defaultItems;
}