Skip to content

feat:批量操作与插件排序(iss#8749)#8774

Open
M1LKT wants to merge 1 commit into
AstrBotDevs:masterfrom
M1LKT:issue#8749
Open

feat:批量操作与插件排序(iss#8749)#8774
M1LKT wants to merge 1 commit into
AstrBotDevs:masterfrom
M1LKT:issue#8749

Conversation

@M1LKT

@M1LKT M1LKT commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Modifications / 改动点

已安装插件页面支持排序与批量操作Closes #8749

为已安装插件页面新增排序控件和多选批量操作功能,方便插件数量较多时快速管理和操作。

排序功能:

  • 在已安装插件页面的工具栏中新增排序下拉框和升降序切换按钮(复用已有的 PluginSortControl 组件)
  • 支持 5 种排序方式:默认排序(保留原有:置顶优先 → 系统插件优先 → 字母序)、名称、作者、启用状态、更新状态
  • 非默认排序模式下,置顶插件仍浮动到排序结果顶部
  • 排序状态与置顶(Pin)系统兼容

批量操作功能:

  • 每张插件卡片标题行新增复选框,常态显示,点击即可进入多选模式并选中该插件
  • 进入多选模式后,工具栏变为批量操作栏,显示已选数量、全选/取消全选/反选按钮,以及批量启用/禁用/卸载按钮
  • 批量启用/禁用操作完成后清空选择但保留多选模式,方便继续下一轮操作
  • 批量卸载操作完成后退出多选模式(已卸载的插件不再存在)
  • 批量卸载确认对话框支持勾选"删除配置文件"和"删除数据"选项
  • 系统插件(reserved)在批量卸载时自动跳过,并有提示
  • 批量操作使用 Promise.allSettled 并行调用单个 API,操作完成后展示成功/失败数通知

改动文件:

  1. src/views/extension/useExtensionPage.js

    • 新增 installedSortBy / installedSortOrder 排序状态
    • 将置顶逻辑从 InstalledPluginsTab.vue 移入此文件(pinnedExtensionNamesisPinnedExtensiontogglePinnedExtension 等)
    • 新增 sortedInstalledPlugins computed(含 5 种排序 + 置顶浮动逻辑)
    • 新增批量操作状态和方法(batchSelectionModeselectedPluginNamesselectAllPluginsdeselectAllPluginsinvertSelectionbatchEnablePluginsbatchDisablePluginsbatchUninstallPlugins 等)
    • 新增批量操作确认对话框状态(batchConfirmDialogbatchDeleteConfigbatchDeleteData
  2. src/views/extension/InstalledPluginsTab.vue

    • 移除本地置顶状态(已移至 composable)
    • 工具栏新增 PluginSortControl 排序控件
    • 新增批量操作栏(v-expand-transition 动画切换)
    • 卡片绑定 selectable / selected / @select
    • 点击复选框:非多选模式自动进入多选模式并选中;多选模式下切换选中
    • 批量模式下隐藏安装/更新 FAB 按钮
    • 新增批量操作确认 v-dialog(含卸载的删除选项/数据选项)
  3. src/components/shared/ExtensionCard.vue

    • 新增 selectableselected props 和 select emit
    • 复选框在标题行与启用开关同一行,常态显示
    • 选中状态时卡片边框变为主题色(extension-card--selected
  4. src/i18n/locales/{zh-CN,en-US,ru-RU}/features/extension.json

    • sort.activated:启用状态排序选项
    • batch.*:多选/批量操作相关文案(选择、全选、取消全选、反选、批量启用/禁用/卸载、确认对话框、结果通知)
  • This is NOT a breaking change. / 这不是一个破坏性变更。

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

验证步骤:

  1. 进入已安装插件页面,确认工具栏右侧显示排序下拉框和搜索框
  2. 切换排序方式(默认/名称/作者/启用状态/更新状态),确认列表正确排序
  3. 点击排序旁的升降序按钮,确认排序方向切换正确
  4. 默认排序下确认置顶插件和系统插件顺序与之前版本一致
  5. 点击任意插件卡片上的复选框,确认自动进入多选模式并选中该插件
  6. 使用全选/取消全选/反选按钮,确认选择状态正确
  7. 批量启用多个插件 -> 确认操作成功,选择清空,仍停留在多选模式
  8. 批量禁用多个插件 -> 确认操作成功,选择清空,仍停留在多选模式
  9. 批量卸载多个插件 -> 确认弹出确认对话框,可选删除配置/数据,确认后卸载成功并退出多选模式
  10. 批量卸载时确认系统插件被跳过,对话框显示跳过数量提示
  11. 点击多选模式关闭按钮(X),确认退出多选模式并清空选择
  12. 确认非多选模式下点击卡片仍可进入插件详情页
  13. 确认插件市场页面功能不受影响

多选模式实机展示:
image

排序展示:
image


Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了"验证步骤"和"运行截图"

  • 🤓 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.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Add sorting controls and batch operations to the installed plugins page to improve plugin management at scale.

New Features:

  • Introduce configurable sorting for installed plugins, supporting multiple sort keys and order while preserving pinned plugin behavior.
  • Add batch selection mode and toolbar on the installed plugins page to enable bulk enable, disable, and uninstall actions with confirmation.
  • Extend plugin cards to support selectable state and visual selection highlighting for use in batch operations.

Enhancements:

  • Refactor installed plugin pinning and sorting logic into the shared extension page composable for centralized state management.
  • Improve uninstall UX with a batch confirmation dialog that can optionally delete plugin configs and data and skips reserved plugins with feedback.

Documentation:

  • Update i18n strings for all supported locales to cover new sorting options and batch operation labels on the extensions page.

@dosubot dosubot Bot added size:XXL This PR changes 1000+ lines, ignoring generated files. area:webui The bug / feature is about webui(dashboard) of astrbot. feature:plugin The bug / feature is about AstrBot plugin system. labels Jun 14, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The three batch operation handlers (batchEnablePlugins, batchDisablePlugins, batchUninstallPlugins) share a lot of nearly identical Promise handling and toast logic; consider extracting a generic helper to reduce duplication and keep behavior consistent when you tweak one of them.
  • Instead of storing a callback function in the reactive batchConfirmDialog.onConfirm, you might make confirmBatchOperation branch on batchConfirmDialog.operation and call the appropriate handler; this avoids keeping arbitrary closures in reactive state and makes the flow easier to follow.
  • In sortedInstalledPlugins, the pinned/unpinned split is implemented twice (default and non-default branches); consider extracting the "float pinned to top by pin order" step into a small helper to simplify the computed and make the pinning behavior easier to reason about.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The three batch operation handlers (`batchEnablePlugins`, `batchDisablePlugins`, `batchUninstallPlugins`) share a lot of nearly identical Promise handling and toast logic; consider extracting a generic helper to reduce duplication and keep behavior consistent when you tweak one of them.
- Instead of storing a callback function in the reactive `batchConfirmDialog.onConfirm`, you might make `confirmBatchOperation` branch on `batchConfirmDialog.operation` and call the appropriate handler; this avoids keeping arbitrary closures in reactive state and makes the flow easier to follow.
- In `sortedInstalledPlugins`, the pinned/unpinned split is implemented twice (default and non-default branches); consider extracting the "float pinned to top by pin order" step into a small helper to simplify the computed and make the pinning behavior easier to reason about.

## Individual Comments

### Comment 1
<location path="dashboard/src/components/shared/ExtensionCard.vue" line_range="244-250" />
<code_context>
             </p>

             <template v-if="!marketMode">
+              <v-checkbox
+                :model-value="selected"
+                density="compact"
+                hide-details
+                color="primary"
+                class="extension-checkbox-inline"
+                @click.stop="emit('select')"
+                @update:model-value="emit('select')"
+              />
</code_context>
<issue_to_address>
**issue (bug_risk):** Clicking the checkbox will likely emit the `select` event twice, causing a double-toggle.

Because the checkbox uses both `@click.stop="emit('select')"` and `@update:model-value="emit('select')"`, a single user click will invoke `emit('select')` twice in Vuetify (once for each handler), which cancels the parent’s toggle logic. Removing one handler—ideally keeping only `@update:model-value` for state changes—will avoid the double emission.
</issue_to_address>

### Comment 2
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="531" />
<code_context>
+    }
+  };
+
+  const batchEnablePlugins = async () => {
+    const names = [...selectedPluginNames.value];
+    if (names.length === 0) return;
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting shared helpers for batch operations, selection management, and pinning/sorting so the new features stay but the control flow and duplication are reduced.

You can keep all the new features but reduce the complexity/duplication by extracting a few small helpers.

### 1. Unify batch operations

`batchEnablePlugins`, `batchDisablePlugins`, and `batchUninstallPlugins` share a lot of flow. You can centralize the Promise/flags/toast logic into a single helper and keep each operation’s differences declarative:

```js
const runBatchOperation = async ({
  getNames,
  callApi,
  onAfterSuccess = async () => {},
  onAfterAll = async () => {},
  successMessage,
}) => {
  const names = getNames();
  if (!names.length) return;

  batchOperationInProgress.value = true;

  const results = await Promise.allSettled(names.map(callApi));

  const succeeded = [];
  const failed = [];
  results.forEach((result, index) => {
    const name = names[index];
    if (result.status === "fulfilled" && result.value.data?.status !== "error") {
      succeeded.push(name);
    } else {
      const errorMsg =
        result.status === "fulfilled"
          ? result.value.data?.message
          : result.reason?.message;
      failed.push({ name, error: errorMsg });
    }
  });

  batchOperationInProgress.value = false;

  await onAfterSuccess({ names, succeeded, failed });
  await onAfterAll({ names, succeeded, failed });

  if (failed.length > 0) {
    toast(
      `${tm("batch.results.title")}: ${tm("batch.results.summary", {
        succeeded: succeeded.length,
        failed: failed.length,
      })}`,
      "warning",
    );
  } else {
    toast(successMessage, "success");
  }
};
```

Then each batch method becomes much smaller:

```js
const batchEnablePlugins = () =>
  runBatchOperation({
    getNames: () => [...selectedPluginNames.value],
    callApi: (name) => axios.post("/api/plugin/on", { name }),
    onAfterSuccess: async () => {
      await getExtensions({ withLoading: false });
      await checkAndPromptConflicts();
    },
    onAfterAll: async () => {
      deselectAllPlugins();
    },
    successMessage:
      tm("batch.results.title") + "" + tm("messages.updateSuccess").replace("!", ""),
  });

const batchDisablePlugins = () =>
  runBatchOperation({
    getNames: () => [...selectedPluginNames.value],
    callApi: (name) => axios.post("/api/plugin/off", { name }),
    onAfterSuccess: async () => {
      await getExtensions({ withLoading: false });
    },
    onAfterAll: async () => {
      deselectAllPlugins();
    },
    successMessage: tm("batch.results.title") + " — OK",
  });

const batchUninstallPlugins = () =>
  runBatchOperation({
    getNames: () =>
      [...selectedPluginNames.value].filter((name) => {
        const ext = filteredPlugins.value.find((p) => p?.name === name);
        return ext && !ext.reserved;
      }),
    callApi: (name) =>
      axios.post("/api/plugin/uninstall", {
        name,
        delete_config: batchDeleteConfig.value,
        delete_data: batchDeleteData.value,
      }),
    onAfterSuccess: async () => {
      batchDeleteConfig.value = false;
      batchDeleteData.value = false;
      await getExtensions({ withLoading: false });
    },
    onAfterAll: async () => {
      exitBatchSelectionMode();
    },
    successMessage: tm("messages.deleteSuccess"),
  });
```

This keeps all behavior but eliminates most duplicated control flow.

### 2. Simplify selection helpers

There are multiple places that reset `selectedPluginNames` and batch mode. A tiny `clearSelection` helper can reduce repetition and clarify intent:

```js
const clearSelection = () => {
  selectedPluginNames.value = new Set();
};

const toggleBatchSelectionMode = () => {
  batchSelectionMode.value = !batchSelectionMode.value;
  if (!batchSelectionMode.value) clearSelection();
};

const exitBatchSelectionMode = () => {
  batchSelectionMode.value = false;
  clearSelection();
};

const deselectAllPlugins = clearSelection;
```

You keep the public API the same, but the logic is centralized and less error-prone.

### 3. Extract sorting & pinning helpers

`sortedInstalledPlugins` mixes filtering, pinning, and multiple comparators. You can pull out pinning and comparators to flatten the computed:

```js
const applyPinning = (plugins) => {
  const pinnedNames = new Set(pinnedExtensionNames.value);
  if (!pinnedNames.size) return plugins;

  const pinned = [];
  const unpinned = [];

  for (const p of plugins) {
    (p && pinnedNames.has(p.name) ? pinned : unpinned).push(p);
  }

  pinned.sort((a, b) => {
    const aIdx = pinnedExtensionNames.value.indexOf(a?.name);
    const bIdx = pinnedExtensionNames.value.indexOf(b?.name);
    return aIdx - bIdx;
  });

  return [...pinned, ...unpinned];
};

const compareByName = (a, b) =>
  normalizeStr(a?.display_name || a?.name || "").localeCompare(
    normalizeStr(b?.display_name || b?.name || ""),
    undefined,
    { sensitivity: "base" },
  );

const compareByAuthor = (a, b) => {
  const getAuthor = (ext) => {
    const author = ext?.author;
    if (Array.isArray(author)) return author.join(", ").toLowerCase();
    return String(author || "").toLowerCase();
  };
  return getAuthor(a).localeCompare(getAuthor(b));
};

const compareByActivated = (a, b) =>
  Number(!!b?.activated) - Number(!!a?.activated);

const compareByUpdateStatus = (a, b) =>
  Number(!!b?.has_update) - Number(!!a?.has_update);

const applySortOrder = (result, order) =>
  order === "desc" ? -result : result;
```

Then `sortedInstalledPlugins` becomes:

```js
const sortedInstalledPlugins = computed(() => {
  const plugins = [...filteredPlugins.value];

  if (installedSortBy.value === "default") {
    return applyPinning(plugins);
  }

  const { value: sortBy } = installedSortBy;
  const { value: sortOrder } = installedSortOrder;

  const comparatorMap = {
    name: compareByName,
    author: compareByAuthor,
    activated: compareByActivated,
    updateStatus: compareByUpdateStatus,
  };

  const comparator = comparatorMap[sortBy];
  if (comparator) {
    plugins.sort((a, b) => applySortOrder(comparator(a, b), sortOrder));
  }

  return applyPinning(plugins);
});
```

This keeps the same behavior (including default vs explicit sort) but separates concerns and removes duplicated pinning logic.

### 4. Drop `pinnedExtensionOrder` Map if you don’t need it

If pin order is always the array order, you can avoid the extra computed `Map` and just use `indexOf` for sorting and a simple presence check:

```js
// remove pinnedExtensionOrder computed

const isPinnedExtension = (extension) => {
  const name = extension?.name;
  return !!name && pinnedExtensionNames.value.includes(name);
};

// applyPinning (above) uses `indexOf` instead of order Map
```

If perf becomes a concern, you could still build a `Set` or `Map` locally inside `applyPinning` instead of keeping a global computed, which makes the state shape simpler to reason about.
</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.

Comment on lines +244 to +250
<v-checkbox
:model-value="selected"
density="compact"
hide-details
color="primary"
class="extension-checkbox-inline"
@click.stop="emit('select')"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (bug_risk): Clicking the checkbox will likely emit the select event twice, causing a double-toggle.

Because the checkbox uses both @click.stop="emit('select')" and @update:model-value="emit('select')", a single user click will invoke emit('select') twice in Vuetify (once for each handler), which cancels the parent’s toggle logic. Removing one handler—ideally keeping only @update:model-value for state changes—will avoid the double emission.

}
};

const batchEnablePlugins = async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

issue (complexity): Consider extracting shared helpers for batch operations, selection management, and pinning/sorting so the new features stay but the control flow and duplication are reduced.

You can keep all the new features but reduce the complexity/duplication by extracting a few small helpers.

1. Unify batch operations

batchEnablePlugins, batchDisablePlugins, and batchUninstallPlugins share a lot of flow. You can centralize the Promise/flags/toast logic into a single helper and keep each operation’s differences declarative:

const runBatchOperation = async ({
  getNames,
  callApi,
  onAfterSuccess = async () => {},
  onAfterAll = async () => {},
  successMessage,
}) => {
  const names = getNames();
  if (!names.length) return;

  batchOperationInProgress.value = true;

  const results = await Promise.allSettled(names.map(callApi));

  const succeeded = [];
  const failed = [];
  results.forEach((result, index) => {
    const name = names[index];
    if (result.status === "fulfilled" && result.value.data?.status !== "error") {
      succeeded.push(name);
    } else {
      const errorMsg =
        result.status === "fulfilled"
          ? result.value.data?.message
          : result.reason?.message;
      failed.push({ name, error: errorMsg });
    }
  });

  batchOperationInProgress.value = false;

  await onAfterSuccess({ names, succeeded, failed });
  await onAfterAll({ names, succeeded, failed });

  if (failed.length > 0) {
    toast(
      `${tm("batch.results.title")}: ${tm("batch.results.summary", {
        succeeded: succeeded.length,
        failed: failed.length,
      })}`,
      "warning",
    );
  } else {
    toast(successMessage, "success");
  }
};

Then each batch method becomes much smaller:

const batchEnablePlugins = () =>
  runBatchOperation({
    getNames: () => [...selectedPluginNames.value],
    callApi: (name) => axios.post("/api/plugin/on", { name }),
    onAfterSuccess: async () => {
      await getExtensions({ withLoading: false });
      await checkAndPromptConflicts();
    },
    onAfterAll: async () => {
      deselectAllPlugins();
    },
    successMessage:
      tm("batch.results.title") + " — " + tm("messages.updateSuccess").replace("!", ""),
  });

const batchDisablePlugins = () =>
  runBatchOperation({
    getNames: () => [...selectedPluginNames.value],
    callApi: (name) => axios.post("/api/plugin/off", { name }),
    onAfterSuccess: async () => {
      await getExtensions({ withLoading: false });
    },
    onAfterAll: async () => {
      deselectAllPlugins();
    },
    successMessage: tm("batch.results.title") + " — OK",
  });

const batchUninstallPlugins = () =>
  runBatchOperation({
    getNames: () =>
      [...selectedPluginNames.value].filter((name) => {
        const ext = filteredPlugins.value.find((p) => p?.name === name);
        return ext && !ext.reserved;
      }),
    callApi: (name) =>
      axios.post("/api/plugin/uninstall", {
        name,
        delete_config: batchDeleteConfig.value,
        delete_data: batchDeleteData.value,
      }),
    onAfterSuccess: async () => {
      batchDeleteConfig.value = false;
      batchDeleteData.value = false;
      await getExtensions({ withLoading: false });
    },
    onAfterAll: async () => {
      exitBatchSelectionMode();
    },
    successMessage: tm("messages.deleteSuccess"),
  });

This keeps all behavior but eliminates most duplicated control flow.

2. Simplify selection helpers

There are multiple places that reset selectedPluginNames and batch mode. A tiny clearSelection helper can reduce repetition and clarify intent:

const clearSelection = () => {
  selectedPluginNames.value = new Set();
};

const toggleBatchSelectionMode = () => {
  batchSelectionMode.value = !batchSelectionMode.value;
  if (!batchSelectionMode.value) clearSelection();
};

const exitBatchSelectionMode = () => {
  batchSelectionMode.value = false;
  clearSelection();
};

const deselectAllPlugins = clearSelection;

You keep the public API the same, but the logic is centralized and less error-prone.

3. Extract sorting & pinning helpers

sortedInstalledPlugins mixes filtering, pinning, and multiple comparators. You can pull out pinning and comparators to flatten the computed:

const applyPinning = (plugins) => {
  const pinnedNames = new Set(pinnedExtensionNames.value);
  if (!pinnedNames.size) return plugins;

  const pinned = [];
  const unpinned = [];

  for (const p of plugins) {
    (p && pinnedNames.has(p.name) ? pinned : unpinned).push(p);
  }

  pinned.sort((a, b) => {
    const aIdx = pinnedExtensionNames.value.indexOf(a?.name);
    const bIdx = pinnedExtensionNames.value.indexOf(b?.name);
    return aIdx - bIdx;
  });

  return [...pinned, ...unpinned];
};

const compareByName = (a, b) =>
  normalizeStr(a?.display_name || a?.name || "").localeCompare(
    normalizeStr(b?.display_name || b?.name || ""),
    undefined,
    { sensitivity: "base" },
  );

const compareByAuthor = (a, b) => {
  const getAuthor = (ext) => {
    const author = ext?.author;
    if (Array.isArray(author)) return author.join(", ").toLowerCase();
    return String(author || "").toLowerCase();
  };
  return getAuthor(a).localeCompare(getAuthor(b));
};

const compareByActivated = (a, b) =>
  Number(!!b?.activated) - Number(!!a?.activated);

const compareByUpdateStatus = (a, b) =>
  Number(!!b?.has_update) - Number(!!a?.has_update);

const applySortOrder = (result, order) =>
  order === "desc" ? -result : result;

Then sortedInstalledPlugins becomes:

const sortedInstalledPlugins = computed(() => {
  const plugins = [...filteredPlugins.value];

  if (installedSortBy.value === "default") {
    return applyPinning(plugins);
  }

  const { value: sortBy } = installedSortBy;
  const { value: sortOrder } = installedSortOrder;

  const comparatorMap = {
    name: compareByName,
    author: compareByAuthor,
    activated: compareByActivated,
    updateStatus: compareByUpdateStatus,
  };

  const comparator = comparatorMap[sortBy];
  if (comparator) {
    plugins.sort((a, b) => applySortOrder(comparator(a, b), sortOrder));
  }

  return applyPinning(plugins);
});

This keeps the same behavior (including default vs explicit sort) but separates concerns and removes duplicated pinning logic.

4. Drop pinnedExtensionOrder Map if you don’t need it

If pin order is always the array order, you can avoid the extra computed Map and just use indexOf for sorting and a simple presence check:

// remove pinnedExtensionOrder computed

const isPinnedExtension = (extension) => {
  const name = extension?.name;
  return !!name && pinnedExtensionNames.value.includes(name);
};

// applyPinning (above) uses `indexOf` instead of order Map

If perf becomes a concern, you could still build a Set or Map locally inside applyPinning instead of keeping a global computed, which makes the state shape simpler to reason about.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces batch operations (enable, disable, and uninstall) and advanced sorting/pinning features for installed plugins in the dashboard. It removes a large auto-generated MDI subset CSS file, updates the ExtensionCard component to support selection states, adds localization strings, and implements the frontend logic for batch selection, confirmation dialogs, and API requests. The review feedback highlights several critical issues: a duplicate event emission on the selection checkbox in ExtensionCard.vue that causes selection toggles to cancel themselves out; incorrect usage of search-filtered plugin lists (filteredPlugins) instead of the full installed list (filteredExtensions) during batch confirmation and uninstallation (which leads to incorrect counts or skipped plugins if the search query changes); and a reversed sorting order logic for the updateStatus sorting option.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +244 to +252
<v-checkbox
:model-value="selected"
density="compact"
hide-details
color="primary"
class="extension-checkbox-inline"
@click.stop="emit('select')"
@update:model-value="emit('select')"
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

v-checkbox 上同时绑定了 @click.stop="emit('select')"@update:model-value="emit('select')"。当用户点击复选框时,这两个事件都会被触发,导致 select 事件被发送两次。由于 togglePluginSelection 是通过取反(Set 的 add/delete)来切换选中状态的,触发两次会导致选中状态保持不变或表现异常。\n\n建议将 @click.stop 修改为仅阻止事件冒泡,而不触发 emit,让 @update:model-value 统一处理选中状态的更新。

              <v-checkbox\n                :model-value="selected"\n                density="compact"\n                hide-details\n                color="primary"\n                class="extension-checkbox-inline"\n                @click.stop\n                @update:model-value="emit('select')"\n              />

Comment on lines +506 to +508
const plugins = filteredPlugins.value.filter((p) =>
selected.includes(p?.name),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

showBatchConfirm 中,过滤已选择插件时使用的是 filteredPlugins.value。由于 filteredPlugins 是受搜索框输入过滤的,如果用户在搜索过滤状态下进行了选择,然后更改了搜索内容,那么那些已被选择但当前被搜索过滤隐藏的插件将无法在 showBatchConfirm 中被正确统计(例如系统插件跳过数量等)。\n\n建议使用 filteredExtensions.value(包含所有已安装插件,不受搜索过滤影响)来获取完整的已选择插件信息。

    const plugins = filteredExtensions.value.filter((p) =>\n      selected.includes(p?.name),\n    );

const batchUninstallPlugins = async () => {
// 跳过保留插件
const names = [...selectedPluginNames.value].filter((name) => {
const ext = filteredPlugins.value.find((p) => p?.name === name);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

batchUninstallPlugins 中,过滤保留插件时使用的是 filteredPlugins.value。这会导致如果用户在搜索过滤状态下选择了某些插件,然后更改了搜索内容,那些已被选择外但当前被搜索过滤隐藏的插件在批量卸载时会被错误地过滤掉(无法被卸载)。\n\n建议使用 filteredExtensions.value(包含所有已安装插件,不受搜索过滤影响)来查找并过滤保留插件。

Suggested change
const ext = filteredPlugins.value.find((p) => p?.name === name);
const ext = filteredExtensions.value.find((p) => p?.name === name);

case "updateStatus":
plugins.sort((a, b) => {
const result = Number(!!b?.has_update) - Number(!!a?.has_update);
return installedSortOrder.value === "desc" ? -result : result;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

updateStatus 排序逻辑中,升降序的返回值方向反了。当 installedSortOrder"desc"(降序)时,返回了 -result(升序,无更新的排在前面);而为 "asc"(升序)时,返回了 result(降序,有更新的排在前面)。这与 activated 排序逻辑不一致,且不符合用户的直觉。\n\n建议将 installedSortOrder.value === "desc" ? -result : result 修改为 installedSortOrder.value === "asc" ? -result : result

Suggested change
return installedSortOrder.value === "desc" ? -result : result;
return installedSortOrder.value === "asc" ? -result : result;

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. feature:plugin The bug / feature is about AstrBot plugin system. size:XXL This PR changes 1000+ lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] 希望已安装插件页面支持排序和多选

1 participant