Skip to content

fix(dashboard): 自定义侧边栏中错误展开“更多功能”页面问题 (#5405)#5670

Merged
Soulter merged 1 commit intoAstrBotDevs:masterfrom
catDforD:fix/5405
Mar 3, 2026
Merged

fix(dashboard): 自定义侧边栏中错误展开“更多功能”页面问题 (#5405)#5670
Soulter merged 1 commit intoAstrBotDevs:masterfrom
catDforD:fix/5405

Conversation

@catDforD
Copy link
Contributor

@catDforD catDforD commented Mar 2, 2026

Fixes #5405

Modifications / 改动点

本 PR 修复了侧边栏在自定义后分组状态不稳定的问题,并增强了本地持久化数据的容错能力。

  • 修改 dashboard/src/layouts/full/vertical-sidebar/NavItem.vue

  • 子项渲染 key 从索引改为稳定 key(title/to/fallback),避免 vnode 复用错位。

  • 修改 dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue

  • sidebar_openedItems 做清洗,只保留当前菜单结构中有效的分组 key。

  • 自定义变化后刷新菜单,并重新清洗展开状态。

  • 顶层侧边栏渲染改为稳定 key。

  • 修改 dashboard/src/utils/sidebarCustomization.js

  • 规范化 main/more 项配置(去重、过滤无效项、处理冲突)。

  • 将规范化后的结果回写 localStorage(自愈旧数据)。

  • 增加非数组保护,避免脏 localStorage 数据触发运行时异常。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

现在 v4.18.3 版本中测试现象为:当点击”平台日志“或其它功能时会连带展开“更多功能”,将其移出侧边栏后再移回去,该现象则会消失,但是当重启 AstrBot 后该现象依旧存在,如下视频,

redpandacompress_bug.mp4

经修复过后,点击“平台日志”或其它功能不会连带展开“更多功能”,重启 AstrBot 后也不会有影响,测试结果如下视频,

redpandacompress_new.mp4

验证步骤:

  1. 在设置页调整侧边栏主区/更多区顺序与归属并保存。
  2. 返回侧边栏页面,检查分组展开状态是否正常。
  3. 刷新页面后再次检查状态是否与当前菜单一致。
  4. (可选)多标签页测试自定义同步后状态是否正确。

本地验证:

  • cd dashboard && pnpm typecheck:通过
  • cd dashboard && pnpm build:通过(有既有 CSS minify warning,不影响构建)
  • cd dashboard && pnpm lint:当前分支存在全局解析基线问题(非本 PR 引入)
  • ruff check .:通过

说明:

  • 记得 ctrl + F5 清一下浏览器缓存(泪目)

Checklist / 检查清单

  • 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
  • 👀 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”。/ My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
  • 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 requirements.txtpyproject.toml 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
  • 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.

Summary by Sourcery

通过使项目键值和持久化状态对自定义及本地陈旧数据更具鲁棒性,稳定仪表盘垂直侧边栏的行为。

Bug 修复:

  • 防止在侧边栏自定义或应用重启后,“More features” 分组和其他侧边栏部分被错误地一起展开。
  • 避免由于 localStorage 中格式错误或过期的自定义数据而导致的运行时错误和侧边栏状态不一致问题。

改进:

  • 规范化并去重主侧边栏/“更多”侧边栏的自定义条目配置,自动处理无效或冲突项,并将清理后的版本写回 localStorage。
  • 过滤并同步持久化的侧边栏分组展开状态,只存储和恢复当前菜单结构中存在的有效分组。
  • 为顶级和子级侧边栏条目使用稳定的键,以避免在菜单结构变化时出现 vnode 重用问题。
Original summary in English

Summary by Sourcery

Stabilize the dashboard vertical sidebar behavior by making item keys and persisted state resilient to customization and stale local data.

Bug Fixes:

  • Prevent the 'More features' group and other sidebar sections from incorrectly expanding together after sidebar customization or app restart.
  • Avoid runtime errors and inconsistent sidebar state caused by malformed or outdated customization data in localStorage.

Enhancements:

  • Normalize and de-duplicate customized main/more sidebar item configurations, automatically resolving invalid or conflicting entries and writing back a cleaned version to localStorage.
  • Filter and synchronize persisted sidebar group expansion state so that only valid groups from the current menu structure are stored and restored.
  • Use stable keys for top-level and child sidebar items to avoid vnode reuse issues when the menu structure changes.

- 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
@auto-assign auto-assign bot requested review from Raven95676 and advent259141 March 2, 2026 10:03
@dosubot dosubot bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Mar 2, 2026
@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

此拉取请求旨在解决仪表盘侧边栏自定义后分组状态不稳定以及“更多功能”页面错误展开的问题。通过优化 v-for 渲染 key 的稳定性、增强本地持久化数据的容错性,并对侧边栏配置进行规范化处理,确保了用户自定义设置的正确性和持久性,显著提升了侧边栏的用户体验和稳定性。

Highlights

  • 侧边栏状态稳定性: 修复了自定义侧边栏后分组状态不稳定的问题,确保用户自定义设置的正确性和持久性。
  • 本地持久化数据容错: 增强了本地持久化数据的容错能力,通过清洗和规范化存储数据,避免了脏数据导致的运行时异常。
  • V-for Key 优化: 在 NavItem.vueVerticalSidebar.vue 中,将 v-for 渲染 key 从索引改为更稳定的 key(如 title/to/fallback),以避免 vnode 复用错位问题。
  • 侧边栏展开状态管理: 对 sidebar_openedItems 进行清洗,只保留当前菜单结构中有效的分组 key,并在自定义变化后刷新菜单并重新清洗展开状态。
  • 侧边栏配置规范化: 在 sidebarCustomization.js 中规范化 main/more 项配置,包括去重、过滤无效项和处理主区与更多区之间的冲突,并实现规范化结果回写 localStorage 进行数据自愈。
Changelog
  • dashboard/src/layouts/full/vertical-sidebar/NavItem.vue
    • 更新了 v-for 循环中子项的 key 属性,从 index 改为 child.title || child.to || child-${index},以提供更稳定的 key
  • dashboard/src/layouts/full/vertical-sidebar/VerticalSidebar.vue
    • 新增 collectGroupValues 函数,用于收集菜单项中的所有有效分组 title
    • 新增 sanitizeOpenedItems 函数,用于清洗 localStorage 中存储的展开项,确保只包含当前菜单结构中存在的有效分组。
    • 新增 getInitialOpenedItems 函数,用于在组件初始化时获取并清洗侧边栏的初始展开状态。
    • 修改 openedItems 的初始化逻辑,使用 getInitialOpenedItems 获取初始值。
    • 修改 openedItemswatch 逻辑,在保存到 localStorage 前对值进行清洗。
    • 新增 refreshSidebarMenu 函数,用于在侧边栏自定义配置变化时刷新菜单并重新清洗展开状态。
    • 更新 handleStorageChangehandleCustomEvent 回调,调用 refreshSidebarMenu
    • 更新顶层侧边栏 v-for 循环的 key 属性,从 i 改为 item.title || item.to || sidebar-item-${i}
  • dashboard/src/utils/sidebarCustomization.js
    • 新增 normalizeKeys 辅助函数,用于规范化 mainItemsmoreItems 数组,包括去重、过滤非字符串项。
    • resolveSidebarItems 函数中,对 mainKeysmoreKeys 进行过滤,确保只包含有效的菜单项。
    • 增加了处理主区和更多区冲突的逻辑,如果同一项同时出现在主区与更多区,主区优先。
    • resolveSidebarItems 函数的返回值增加了 normalizedMainKeysnormalizedMoreKeys
    • applySidebarCustomization 函数中,如果规范化后的 mainItemsmoreItems 与原始存储在 localStorage 中的值不同,则将规范化后的结果回写 localStorage,实现数据自愈。
Activity
  • 此拉取请求自创建以来,尚未有其他用户评论或审查活动。
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@dosubot dosubot bot added the area:webui The bug / feature is about webui(dashboard) of astrbot. label Mar 2, 2026
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey——我这边发现了 1 个问题,并补充了一些整体性的反馈:

  • VerticalSidebar.vueNavItem.vue 里使用 item.title || item.to 作为 :key,如果不同分组之间存在重复的 title 或路由,仍然有可能发生 key 冲突;建议为每个菜单项新增或派生一个保证唯一的标识符(例如稳定的 id 或完整路径),并用它作为 key。
  • sidebar_openedItems 这个 localStorage key 字符串现在在多个地方被引用(getInitialOpenedItemswatch 等);可以考虑把它集中到一个共享常量里,这样将来如果需要修改 key,就能减少出现细微 bug 的风险。
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Using `item.title || item.to` as the `:key` in both `VerticalSidebar.vue` and `NavItem.vue` may still lead to collisions if titles or routes repeat across groups; consider adding or deriving a guaranteed-unique identifier for each menu item (e.g., a stable `id` or full path) and using that as the key.
- The `sidebar_openedItems` localStorage key string is now referenced in multiple places (`getInitialOpenedItems`, the `watch`, etc.); centralizing this into a shared constant will reduce the chance of subtle bugs if the key ever needs to change.

## Individual Comments

### Comment 1
<location path="dashboard/src/utils/sidebarCustomization.js" line_range="55" />
<code_context>
 export function resolveSidebarItems(defaultItems, customization, options = {}) {
   const { cloneItems = false, assembleMoreGroup = false } = options;

+  const normalizeKeys = (keys = []) => {
+    const list = Array.isArray(keys) ? keys : [];
+    const deduped = [];
</code_context>
<issue_to_address>
**issue (complexity):** 可以考虑抽取一个可复用的侧边栏自定义配置归一化(normalization)辅助方法,并让 `applySidebarCustomization` 负责持久化逻辑,这样 `resolveSidebarItems` 就能专注在根据 key 解析 items 上。

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

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

### 1. 抽取归一化辅助函数

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

```js
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:

```js
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`

```js
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`)是一条单一的、可组合的流水线,被 `resolveSidebarItems``applySidebarCustomization` 共同复用。
- `applySidebarCustomization` 通过 `areArraysShallowEqual` 而不是 `JSON.stringify` 来进行清晰的持久化与变更检测。
</issue_to_address>

Sourcery 对开源项目免费使用——如果你觉得这些评审有帮助,欢迎分享 ✨
帮我变得更有用!请在每条评论上点 👍 或 👎,我会根据你的反馈改进后续的评审。
Original comment in English

Hey - I've found 1 issue, and left some high level feedback:

  • Using item.title || item.to as the :key in both VerticalSidebar.vue and NavItem.vue may still lead to collisions if titles or routes repeat across groups; consider adding or deriving a guaranteed-unique identifier for each menu item (e.g., a stable id or full path) and using that as the key.
  • The sidebar_openedItems localStorage key string is now referenced in multiple places (getInitialOpenedItems, the watch, etc.); centralizing this into a shared constant will reduce the chance of subtle bugs if the key ever needs to change.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Using `item.title || item.to` as the `:key` in both `VerticalSidebar.vue` and `NavItem.vue` may still lead to collisions if titles or routes repeat across groups; consider adding or deriving a guaranteed-unique identifier for each menu item (e.g., a stable `id` or full path) and using that as the key.
- The `sidebar_openedItems` localStorage key string is now referenced in multiple places (`getInitialOpenedItems`, the `watch`, etc.); centralizing this into a shared constant will reduce the chance of subtle bugs if the key ever needs to change.

## Individual Comments

### Comment 1
<location path="dashboard/src/utils/sidebarCustomization.js" line_range="55" />
<code_context>
 export function resolveSidebarItems(defaultItems, customization, options = {}) {
   const { cloneItems = false, assembleMoreGroup = false } = options;

+  const normalizeKeys = (keys = []) => {
+    const list = Array.isArray(keys) ? keys : [];
+    const deduped = [];
</code_context>
<issue_to_address>
**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:

```js
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:

```js
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`:

```js
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`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

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.

@dosubot
Copy link

dosubot bot commented Mar 2, 2026

Related Documentation

Checked 1 published document(s) in 1 knowledge base(s). No updates required.

How did I do? Any feedback?  Join Discord

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

这个 PR 很好地解决了侧边栏在自定义后状态不稳定的问题。通过引入稳定的 key、对持久化数据进行清洗和规范化,代码的健壮性得到了显著提升。特别是对 localStorage 中可能存在的旧的或脏数据增加了自愈能力,这是一个非常棒的改进。

我发现了一些可以进一步改进的地方,主要在 sidebarCustomization.js 文件中,包括一个潜在的 bug 修复和一处代码重构建议,详见具体的评论。

Comment on lines +177 to +181
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);
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);

Comment on lines +55 to +68
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;
};
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'))];
  };

@dosubot dosubot bot added the lgtm This PR has been approved by a maintainer label Mar 3, 2026
@Soulter Soulter merged commit 82e7502 into AstrBotDevs:master Mar 3, 2026
1 check passed
@astrbot-doc-agent
Copy link

No docs changes were generated in this run (docs repo had no updates).

Docs repo: AstrBotDevs/AstrBot-docs
Trigger: PR merged


AI change summary (not committed):


Experimental bot notice:

  • This output is generated by AstrBot-Doc-Agent for review only.
  • It does not represent the final documentation form.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:webui The bug / feature is about webui(dashboard) of astrbot. lgtm This PR has been approved by a maintainer size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

关于自定义侧边栏的功能打开问题

2 participants