X Tutup
Skip to content

Closes #5771 新增插件分享功能#5799

Open
united-pooh wants to merge 7 commits intoAstrBotDevs:masterfrom
united-pooh:feat/plugin_share
Open

Closes #5771 新增插件分享功能#5799
united-pooh wants to merge 7 commits intoAstrBotDevs:masterfrom
united-pooh:feat/plugin_share

Conversation

@united-pooh
Copy link

@united-pooh united-pooh commented Mar 6, 2026

提供一个“轻量快速分享插件组合”的能力,作为整合包分发的平替方案。
用户可以在插件管理页快速选定一组插件,一键生成分享码;其他人导入该分享码后可自动批量安装对应插件仓库,实现“轻量、快捷、可复制”的分享体验。

Modifications / 改动点

本次主要实现了“分享码一键分享/导入”完整链路:

    分享选择能力(已安装插件页)
    支持进入分享模式并选择多个插件(列表/卡片视图)
    支持全选/取消全选
    选中数量实时反馈
    分享模式下增加交互约束与视觉反馈(如禁用文本高亮、选中态显示)
    分享码生成
    基于已选插件的仓库地址生成分享码
    自动复制到剪贴板,便于直接发送给他人
    使用 astrbot-share: 前缀格式,便于识别
    分享码导入与批量安装
    在安装弹窗中新增“从分享码导入”页签(与“从链接安装”并列)
    解析分享码后批量调用安装流程,实现“一键导入并安装”
  • This is NOT a breaking change. / 这不是一个破坏性变更。

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

image image image

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

添加基于分享代码的工作流,用于选择已安装插件进行分享,以及导入分享的插件集合,包括批量安装与进度反馈。

New Features:

  • 允许用户在已安装插件页面进入分享模式,选择多个插件,并生成可复用的分享代码,用于编码这些插件的仓库地址。
  • 在安装对话框中支持通过分享代码安装插件,解码其中包含的仓库 URL,并执行带有冲突检查的批量安装。

Enhancements:

  • 改进插件安装加载对话框,可选显示可确定的进度信息,包括计数以及当前正在处理的 URL。
  • 更新已安装插件列表和卡片视图,以支持分享选择控件和视觉状态,并且不干扰现有操作。

Build:

  • 添加 lz-string 依赖,用于对插件分享代码进行紧凑的编码与解码。
Original summary in English

Summary by Sourcery

Add a share-code based workflow for selecting installed plugins to share and importing shared plugin sets, including batch installation and progress feedback.

New Features:

  • Allow users to enter a share mode on the installed plugins page, select multiple plugins, and generate a reusable share code encoding their repositories.
  • Support installing plugins from a share code in the install dialog, decoding included repository URLs and performing batch installation with conflict checks.

Enhancements:

  • Improve the plugin installation loading dialog to optionally show determinate progress with counts and the currently processed URL.
  • Update installed plugin list and card views to support share selection controls and visual states without interfering with existing actions.

Build:

  • Add the lz-string dependency to compactly encode and decode plugin share codes.

新功能:

  • 允许用户在“已安装插件”页面进入分享模式,选择多个插件并为其仓库生成可复用的分享码。
  • 在安装对话框中支持通过分享码导入插件,对其中包含的仓库 URL 进行解码,并在进行冲突检查的前提下批量安装。

增强:

  • 改进插件安装加载对话框,可选显示可量化的进度信息,包括当前/总计数量以及当前正在处理的 URL。
  • 调整已安装插件列表和卡片界面,以支持分享选择控件和可视化状态,并且不影响现有操作。

构建:

  • 新增 lz-string 依赖,用于对插件分享码进行紧凑的编码与解码。
Original summary in English

Summary by Sourcery

添加基于分享代码的工作流,用于选择已安装插件进行分享,以及导入分享的插件集合,包括批量安装与进度反馈。

New Features:

  • 允许用户在已安装插件页面进入分享模式,选择多个插件,并生成可复用的分享代码,用于编码这些插件的仓库地址。
  • 在安装对话框中支持通过分享代码安装插件,解码其中包含的仓库 URL,并执行带有冲突检查的批量安装。

Enhancements:

  • 改进插件安装加载对话框,可选显示可确定的进度信息,包括计数以及当前正在处理的 URL。
  • 更新已安装插件列表和卡片视图,以支持分享选择控件和视觉状态,并且不干扰现有操作。

Build:

  • 添加 lz-string 依赖,用于对插件分享代码进行紧凑的编码与解码。
Original summary in English

Summary by Sourcery

Add a share-code based workflow for selecting installed plugins to share and importing shared plugin sets, including batch installation and progress feedback.

New Features:

  • Allow users to enter a share mode on the installed plugins page, select multiple plugins, and generate a reusable share code encoding their repositories.
  • Support installing plugins from a share code in the install dialog, decoding included repository URLs and performing batch installation with conflict checks.

Enhancements:

  • Improve the plugin installation loading dialog to optionally show determinate progress with counts and the currently processed URL.
  • Update installed plugin list and card views to support share selection controls and visual states without interfering with existing actions.

Build:

  • Add the lz-string dependency to compactly encode and decode plugin share codes.
Original summary in English

Summary by Sourcery

添加基于分享代码的工作流,用于选择已安装插件进行分享,以及导入分享的插件集合,包括批量安装与进度反馈。

New Features:

  • 允许用户在已安装插件页面进入分享模式,选择多个插件,并生成可复用的分享代码,用于编码这些插件的仓库地址。
  • 在安装对话框中支持通过分享代码安装插件,解码其中包含的仓库 URL,并执行带有冲突检查的批量安装。

Enhancements:

  • 改进插件安装加载对话框,可选显示可确定的进度信息,包括计数以及当前正在处理的 URL。
  • 更新已安装插件列表和卡片视图,以支持分享选择控件和视觉状态,并且不干扰现有操作。

Build:

  • 添加 lz-string 依赖,用于对插件分享代码进行紧凑的编码与解码。
Original summary in English

Summary by Sourcery

Add a share-code based workflow for selecting installed plugins to share and importing shared plugin sets, including batch installation and progress feedback.

New Features:

  • Allow users to enter a share mode on the installed plugins page, select multiple plugins, and generate a reusable share code encoding their repositories.
  • Support installing plugins from a share code in the install dialog, decoding included repository URLs and performing batch installation with conflict checks.

Enhancements:

  • Improve the plugin installation loading dialog to optionally show determinate progress with counts and the currently processed URL.
  • Update installed plugin list and card views to support share selection controls and visual states without interfering with existing actions.

Build:

  • Add the lz-string dependency to compactly encode and decode plugin share codes.

新功能:

  • 允许用户在“已安装插件”页面进入分享模式,选择多个插件并为其仓库生成可复用的分享码。
  • 在安装对话框中支持通过分享码导入插件,对其中包含的仓库 URL 进行解码,并在进行冲突检查的前提下批量安装。

增强:

  • 改进插件安装加载对话框,可选显示可量化的进度信息,包括当前/总计数量以及当前正在处理的 URL。
  • 调整已安装插件列表和卡片界面,以支持分享选择控件和可视化状态,并且不影响现有操作。

构建:

  • 新增 lz-string 依赖,用于对插件分享码进行紧凑的编码与解码。
Original summary in English

Summary by Sourcery

添加基于分享代码的工作流,用于选择已安装插件进行分享,以及导入分享的插件集合,包括批量安装与进度反馈。

New Features:

  • 允许用户在已安装插件页面进入分享模式,选择多个插件,并生成可复用的分享代码,用于编码这些插件的仓库地址。
  • 在安装对话框中支持通过分享代码安装插件,解码其中包含的仓库 URL,并执行带有冲突检查的批量安装。

Enhancements:

  • 改进插件安装加载对话框,可选显示可确定的进度信息,包括计数以及当前正在处理的 URL。
  • 更新已安装插件列表和卡片视图,以支持分享选择控件和视觉状态,并且不干扰现有操作。

Build:

  • 添加 lz-string 依赖,用于对插件分享代码进行紧凑的编码与解码。
Original summary in English

Summary by Sourcery

Add a share-code based workflow for selecting installed plugins to share and importing shared plugin sets, including batch installation and progress feedback.

New Features:

  • Allow users to enter a share mode on the installed plugins page, select multiple plugins, and generate a reusable share code encoding their repositories.
  • Support installing plugins from a share code in the install dialog, decoding included repository URLs and performing batch installation with conflict checks.

Enhancements:

  • Improve the plugin installation loading dialog to optionally show determinate progress with counts and the currently processed URL.
  • Update installed plugin list and card views to support share selection controls and visual states without interfering with existing actions.

Build:

  • Add the lz-string dependency to compactly encode and decode plugin share codes.

united_pooh and others added 5 commits March 6, 2026 01:07
…install flow

- add "fromShareCode" tab in extension install dialog
- support generating/copying share codes with astrbot-share prefix
- add share-code parsing and batch URL installs via performInstallRequest
- reuse install dialog entry from InstalledPluginsTab import action
- update i18n messages and upload labels for share-code workflows
- refine share-mode selection UX and card interaction behavior
@auto-assign auto-assign bot requested review from LIghtJUNction and anka-afk March 6, 2026 09:59
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@dosubot dosubot bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Mar 6, 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!

此拉取请求旨在通过引入插件分享功能,提供一种轻量、快速且可复制的插件组合分发方案。它允许用户在插件管理页面选择一组插件,生成一个可分享的代码,其他用户可以通过该代码批量导入并安装这些插件。这极大地简化了插件集合的共享过程,提升了用户体验,并为插件分发提供了整合包之外的灵活替代方案。

Highlights

  • 新增插件分享功能: 引入了全新的插件分享功能,用户可以从已安装的插件中选择一组插件,生成一个分享码,方便快捷地分享给他人。
  • 分享码导入与批量安装: 接收者可以通过导入分享码,自动批量安装分享码中包含的插件仓库,实现了“一键导入并安装”的体验。
  • 用户界面与交互优化: 在插件管理页面增加了分享模式,支持多选、全选/取消全选,并提供了选中数量的实时反馈和视觉效果,如禁用文本高亮和选中态显示。
  • 依赖更新与国际化支持: 新增了 lz-string 库用于分享码的压缩与解压缩,并更新了中英文国际化文件,以支持新的分享功能相关文本。
Changelog
  • dashboard/package.json
    • 添加了 lz-string 依赖,用于分享码的压缩和解压缩。
  • dashboard/pnpm-lock.yaml
    • 更新了 pnpm-lock.yaml 文件,以包含新添加的 lz-string 依赖。
  • dashboard/src/components/shared/ExtensionCard.vue
    • 新增 shareModeselected props,以支持分享模式下的插件选择状态。
    • 修改了插件卡片的点击和开关切换逻辑,使其在分享模式下触发选择事件。
    • 添加了分享模式下的样式,包括选中和未选中状态的视觉反馈。
  • dashboard/src/i18n/locales/en-US/features/extension.json
    • 新增了多条英文翻译字符串,用于支持插件分享功能,例如“Share Plugin”、“Confirm Share”和“Import from Share Code”等。
  • dashboard/src/i18n/locales/zh-CN/features/extension.json
    • 新增了多条中文翻译字符串,用于支持插件分享功能,例如“分享插件”、“确认分享”和“从分享码导入”等。
    • 修正了“加载失败插件({count})”的括号格式。
  • dashboard/src/views/ExtensionPage.vue
    • 在插件安装对话框中新增了“从分享码导入”的标签页。
    • 添加了 shareCodeInput 变量来绑定分享码输入框的内容。
    • 修改了安装按钮的点击事件,使其在分享码标签页下调用 installFromShareCode 方法。
  • dashboard/src/views/extension/InstalledPluginsTab.vue
    • 引入了 isShareModeselectedSharePluginCount 等状态变量和相关方法,用于管理分享模式的UI和逻辑。
    • 在插件列表上方添加了“分享插件”按钮,以及分享模式下的操作卡片,包含全选/取消全选和确认分享按钮。
    • 修改了插件列表的行属性和操作按钮的显示逻辑,使其在分享模式下支持插件选择和禁用部分操作。
    • 新增了分享模式下插件列表的样式,包括选中行的背景色和未选中行的透明度。
  • dashboard/src/views/extension/useExtensionPage.js
    • 引入了 lz-string 库的 compressToEncodedURIComponentdecompressFromEncodedURIComponent 方法。
    • 定义了 SHARE_CODE_LZ_PREFIX 常量用于分享码的识别。
    • 新增了 isShareModeselectedSharePluginNames 等响应式状态,以及 toggleShareModecancelShareMode 等方法来控制分享模式的生命周期。
    • 实现了 encodeShareCodedecodeShareCode 方法,用于分享码的生成和解析。
    • 添加了 confirmShareSelection 方法,用于将选中的插件仓库编码为分享码并复制到剪贴板。
    • 实现了 toggleSharePluginSelectiontoggleShareSelectAllisSharePluginSelected 等方法,用于管理分享模式下插件的选择状态。
    • 新增了 installFromShareCode 方法,用于解析分享码并批量安装其中的插件仓库。
    • 添加了 openShareCodeImportDialog 方法,用于直接打开分享码导入对话框。
Activity
  • 作者 united-pooh 提交了此拉取请求,旨在解决 [Feature] 生成插件集合分享链接(批量安装) #5771 提出的需求。
  • 拉取请求描述中明确指出这不是一个破坏性变更,并提供了详细的改动点、运行截图和测试结果,表明作者已进行了充分的测试和验证。
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 feature:plugin The bug / feature is about AstrBot plugin system. label Mar 6, 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 个问题,并给出了一些整体性的反馈:

  • encodeShareCodedecodeShareCodeparseShareCodeRepos 外面包裹的 async 函数本身并没有执行任何异步操作,可以简化为同步函数,从而减少不必要的 await,也让调用链更容易理解。
  • 在分享模式下,全局的 user-select: none 规则(例如 .share-mode-card * { user-select: none; } 以及表格中与分享模式相关的选择器)可能会无意中阻止用户复制插件名称或 URL 等文本;建议更精确地限定这些样式的作用范围,避免在仍然需要正常文本选择的地方进行阻止。
  • installFromShareCode 中顺序执行的 for...of 循环会在导入大量仓库时变得很慢;可以考虑改用 Promise.allSettled 模式并行执行安装,同时仍然收集每个仓库失败的信息用于最终汇总。
给 AI Agent 的提示
Please address the comments from this code review:

## Overall Comments
- `encodeShareCode``decodeShareCode``parseShareCodeRepos` 外面包裹的 async 函数本身并没有执行任何异步操作,可以简化为同步函数,从而减少不必要的 `await`,也让调用链更容易理解。
- 在分享模式下,全局的 `user-select: none` 规则(例如 `.share-mode-card * { user-select: none; }` 以及表格中与分享模式相关的选择器)可能会无意中阻止用户复制插件名称或 URL 等文本;建议更精确地限定这些样式的作用范围,避免在仍然需要正常文本选择的地方进行阻止。
- `installFromShareCode` 中顺序执行的 `for...of` 循环会在导入大量仓库时变得很慢;可以考虑改用 `Promise.allSettled` 模式并行执行安装,同时仍然收集每个仓库失败的信息用于最终汇总。

## Individual Comments

### Comment 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="149" />
<code_context>
+    isShareMode.value = true;
+    selectedSharePluginNames.value = new Set();
+  };
+  const encodeShareCode = async (payload) => {
+    const rawJson = JSON.stringify(payload);
+    const compressed = compressToEncodedURIComponent(rawJson);
</code_context>
<issue_to_address>
**issue (complexity):** 可以考虑通过将 share-code 相关的辅助函数改为同步、集中管理 Set 的操作逻辑,并把批量安装循环提取到一个小的辅助函数中,从而简化新分享功能的实现。

在保持现有功能行为不变的前提下,这些调整能够在几个关键位置降低复杂度。

### 1. 去掉纯辅助函数中不必要的 `async`

`encodeShareCode``decodeShareCode``parseShareCodeRepos` 实际上是同步的。将它们改成同步可以减轻理解负担,并简化调用端代码。

```js
// keep at top-level in this file or move to a separate util later
const encodeShareCode = (payload) => {
  const rawJson = JSON.stringify(payload);
  const compressed = compressToEncodedURIComponent(rawJson);
  if (!compressed) return rawJson;
  return `${SHARE_CODE_LZ_PREFIX}${compressed}`;
};

const decodeShareCode = (rawCode) => {
  if (rawCode.startsWith(SHARE_CODE_LZ_PREFIX)) {
    const encodedPart = rawCode.slice(SHARE_CODE_LZ_PREFIX.length);
    const decompressed = decompressFromEncodedURIComponent(encodedPart);
    if (!decompressed) {
      throw new Error("Invalid lz share code");
    }
    return decompressed;
  }
  return rawCode;
};

const parseShareCodeRepos = (rawCode) => {
  const decodedContent = decodeShareCode(rawCode);
  const parsed = JSON.parse(decodedContent);
  const repos = Array.isArray(parsed?.repos) ? parsed.repos : [];
  return repos
    .filter((repo) => typeof repo === "string")
    .map((repo) => repo.trim())
    .filter((repo) => repo.length > 0);
};
```

然后以同步方式调用:

```js
const confirmShareSelection = async () => {
  // ...
  const shareCode = encodeShareCode({ repos: uniqueRepos });
  // ...
};

const installFromShareCode = async (ignoreVersionCheck = false) => {
  // ...
  let repos = [];
  try {
    repos = parseShareCodeRepos(rawCode);
  } catch (error) {
    toast(tm("messages.invalidShareCode"), "error");
    return;
  }
  // ...
};
```

### 2. 抽取并复用分享选择中 `Set` 的变更 / 守卫逻辑

`selectedSharePluginNames` 的修改和相关的防御性检查逻辑在多个函数中重复出现。通过一个小的辅助函数可以把这些模式集中在一个地方,同时简化各个开关函数。

```js
const selectedSharePluginNames = ref(new Set());

const updateSelectedSharePlugins = (updater) => {
  const next = new Set(selectedSharePluginNames.value);
  updater(next);
  selectedSharePluginNames.value = next;
};

const normalizePluginNames = (pluginNames = []) =>
  pluginNames.filter((name) => typeof name === "string" && name.length > 0);

const toggleSharePluginSelection = (pluginName) => {
  if (!isShareMode.value || !pluginName) return;

  updateSelectedSharePlugins((set) => {
    if (set.has(pluginName)) set.delete(pluginName);
    else set.add(pluginName);
  });
};

const toggleShareSelectAll = (pluginNames = []) => {
  if (!isShareMode.value) return;

  const names = normalizePluginNames(pluginNames);
  if (names.length === 0) return;

  const allSelected = names.every((name) =>
    selectedSharePluginNames.value.has(name),
  );

  updateSelectedSharePlugins((set) => {
    if (allSelected) {
      names.forEach((name) => set.delete(name));
    } else {
      names.forEach((name) => set.add(name));
    }
  });
};

const areAllSharePluginsSelected = (pluginNames = []) => {
  const names = normalizePluginNames(pluginNames);
  if (names.length === 0) return false;
  return names.every((name) => selectedSharePluginNames.value.has(name));
};

const isSharePluginSelected = (pluginName) =>
  !!pluginName && selectedSharePluginNames.value.has(pluginName);
```

这样可以把所有克隆 / 赋值逻辑集中到一个地方,同时统一处理名称校验。

### 3. 将 `installFromShareCode` 中的核心安装循环提取出来

`installFromShareCode` 目前同时处理解析、状态管理和安装循环。把安装循环抽取到一个小的辅助函数中,可以在不改变行为的前提下,让顶层流程更容易阅读和理解。

```js
const installReposBatch = async (repos, { ignoreVersionCheck }) => {
  const failedItems = [];
  let successCount = 0;
  const previousExtensionUrl = extension_url.value;

  try {
    for (const repoUrl of repos) {
      extension_url.value = repoUrl;
      const res = await performInstallRequest({
        source: "url",
        ignoreVersionCheck,
      });
      const resData = res.data || {};
      if (resData.status === "ok") {
        successCount += 1;
      } else {
        failedItems.push({
          url: repoUrl,
          message: resData.message || tm("messages.installFailed"),
        });
      }
    }
  } catch (err) {
    failedItems.push({
      url: extension_url.value || tm("status.unknown"),
      message: resolveErrorMessage(err, tm("messages.installFailed")),
    });
  } finally {
    extension_url.value = previousExtensionUrl;
  }

  return { successCount, failedItems };
};

const installFromShareCode = async (ignoreVersionCheck = false) => {
  const rawCode = (shareCodeInput.value ?? "").trim();
  if (!rawCode) {
    toast(tm("messages.fillShareCode"), "error");
    return;
  }

  let repos = [];
  try {
    repos = parseShareCodeRepos(rawCode);
  } catch {
    toast(tm("messages.invalidShareCode"), "error");
    return;
  }

  const uniqueRepos = [...new Set(repos)];
  if (uniqueRepos.length === 0) {
    toast(tm("messages.noRepoInShareCode"), "error");
    return;
  }

  loading_.value = true;
  loadingDialog.title = tm("status.loading");
  loadingDialog.show = true;

  const { successCount, failedItems } = await installReposBatch(uniqueRepos, {
    ignoreVersionCheck,
  });

  loading_.value = false;

  // rest of the success/partial/failure UI handling stays the same
};
```

这些改动在保持所有现有功能不变的同时,使 share-code 相关逻辑更加聚焦,也更易于维护。
</issue_to_address>

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

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

  • The async wrappers around encodeShareCode, decodeShareCode, and parseShareCodeRepos don’t perform any asynchronous work and could be simplified to synchronous functions to reduce unnecessary awaits and make the call chain easier to follow.
  • In share mode, the global user-select: none rules (e.g. .share-mode-card * { user-select: none; } and the table share-mode selectors) may unintentionally prevent copying text such as plugin names or URLs; consider scoping these styles more narrowly to avoid blocking normal text selection where it’s still useful.
  • The sequential for...of loop in installFromShareCode can make importing many repos slow; consider switching to a Promise.allSettled pattern to perform installs in parallel while still collecting per-repo failures for the summary.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The async wrappers around `encodeShareCode`, `decodeShareCode`, and `parseShareCodeRepos` don’t perform any asynchronous work and could be simplified to synchronous functions to reduce unnecessary `await`s and make the call chain easier to follow.
- In share mode, the global `user-select: none` rules (e.g. `.share-mode-card * { user-select: none; }` and the table share-mode selectors) may unintentionally prevent copying text such as plugin names or URLs; consider scoping these styles more narrowly to avoid blocking normal text selection where it’s still useful.
- The sequential `for...of` loop in `installFromShareCode` can make importing many repos slow; consider switching to a `Promise.allSettled` pattern to perform installs in parallel while still collecting per-repo failures for the summary.

## Individual Comments

### Comment 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="149" />
<code_context>
+    isShareMode.value = true;
+    selectedSharePluginNames.value = new Set();
+  };
+  const encodeShareCode = async (payload) => {
+    const rawJson = JSON.stringify(payload);
+    const compressed = compressToEncodedURIComponent(rawJson);
</code_context>
<issue_to_address>
**issue (complexity):** Consider simplifying the new share-code feature by making its helpers synchronous, centralizing Set manipulation, and extracting the batch install loop into a small helper for clearer flow.

You can keep the new feature as-is functionally while reducing complexity in a few tight spots.

### 1. Remove unnecessary `async` from pure helpers

`encodeShareCode`, `decodeShareCode`, and `parseShareCodeRepos` are synchronous. Making them sync reduces mental overhead and simplifies call sites.

```js
// keep at top-level in this file or move to a separate util later
const encodeShareCode = (payload) => {
  const rawJson = JSON.stringify(payload);
  const compressed = compressToEncodedURIComponent(rawJson);
  if (!compressed) return rawJson;
  return `${SHARE_CODE_LZ_PREFIX}${compressed}`;
};

const decodeShareCode = (rawCode) => {
  if (rawCode.startsWith(SHARE_CODE_LZ_PREFIX)) {
    const encodedPart = rawCode.slice(SHARE_CODE_LZ_PREFIX.length);
    const decompressed = decompressFromEncodedURIComponent(encodedPart);
    if (!decompressed) {
      throw new Error("Invalid lz share code");
    }
    return decompressed;
  }
  return rawCode;
};

const parseShareCodeRepos = (rawCode) => {
  const decodedContent = decodeShareCode(rawCode);
  const parsed = JSON.parse(decodedContent);
  const repos = Array.isArray(parsed?.repos) ? parsed.repos : [];
  return repos
    .filter((repo) => typeof repo === "string")
    .map((repo) => repo.trim())
    .filter((repo) => repo.length > 0);
};
```

Then call them synchronously:

```js
const confirmShareSelection = async () => {
  // ...
  const shareCode = encodeShareCode({ repos: uniqueRepos });
  // ...
};

const installFromShareCode = async (ignoreVersionCheck = false) => {
  // ...
  let repos = [];
  try {
    repos = parseShareCodeRepos(rawCode);
  } catch (error) {
    toast(tm("messages.invalidShareCode"), "error");
    return;
  }
  // ...
};
```

### 2. DRY up `Set` mutation / guard logic for share selection

The `selectedSharePluginNames` manipulation and guard checks are repeated across multiple functions. A tiny helper keeps those patterns in one place and shrinks the toggles.

```js
const selectedSharePluginNames = ref(new Set());

const updateSelectedSharePlugins = (updater) => {
  const next = new Set(selectedSharePluginNames.value);
  updater(next);
  selectedSharePluginNames.value = next;
};

const normalizePluginNames = (pluginNames = []) =>
  pluginNames.filter((name) => typeof name === "string" && name.length > 0);

const toggleSharePluginSelection = (pluginName) => {
  if (!isShareMode.value || !pluginName) return;

  updateSelectedSharePlugins((set) => {
    if (set.has(pluginName)) set.delete(pluginName);
    else set.add(pluginName);
  });
};

const toggleShareSelectAll = (pluginNames = []) => {
  if (!isShareMode.value) return;

  const names = normalizePluginNames(pluginNames);
  if (names.length === 0) return;

  const allSelected = names.every((name) =>
    selectedSharePluginNames.value.has(name),
  );

  updateSelectedSharePlugins((set) => {
    if (allSelected) {
      names.forEach((name) => set.delete(name));
    } else {
      names.forEach((name) => set.add(name));
    }
  });
};

const areAllSharePluginsSelected = (pluginNames = []) => {
  const names = normalizePluginNames(pluginNames);
  if (names.length === 0) return false;
  return names.every((name) => selectedSharePluginNames.value.has(name));
};

const isSharePluginSelected = (pluginName) =>
  !!pluginName && selectedSharePluginNames.value.has(pluginName);
```

This keeps all the cloning/assignment logic in one place and centralizes name validation.

### 3. Extract the core install loop from `installFromShareCode`

`installFromShareCode` currently mixes parsing, state management, and the install loop. Pulling the loop into a small helper makes the top-level flow easier to scan without changing behavior.

```js
const installReposBatch = async (repos, { ignoreVersionCheck }) => {
  const failedItems = [];
  let successCount = 0;
  const previousExtensionUrl = extension_url.value;

  try {
    for (const repoUrl of repos) {
      extension_url.value = repoUrl;
      const res = await performInstallRequest({
        source: "url",
        ignoreVersionCheck,
      });
      const resData = res.data || {};
      if (resData.status === "ok") {
        successCount += 1;
      } else {
        failedItems.push({
          url: repoUrl,
          message: resData.message || tm("messages.installFailed"),
        });
      }
    }
  } catch (err) {
    failedItems.push({
      url: extension_url.value || tm("status.unknown"),
      message: resolveErrorMessage(err, tm("messages.installFailed")),
    });
  } finally {
    extension_url.value = previousExtensionUrl;
  }

  return { successCount, failedItems };
};

const installFromShareCode = async (ignoreVersionCheck = false) => {
  const rawCode = (shareCodeInput.value ?? "").trim();
  if (!rawCode) {
    toast(tm("messages.fillShareCode"), "error");
    return;
  }

  let repos = [];
  try {
    repos = parseShareCodeRepos(rawCode);
  } catch {
    toast(tm("messages.invalidShareCode"), "error");
    return;
  }

  const uniqueRepos = [...new Set(repos)];
  if (uniqueRepos.length === 0) {
    toast(tm("messages.noRepoInShareCode"), "error");
    return;
  }

  loading_.value = true;
  loadingDialog.title = tm("status.loading");
  loadingDialog.show = true;

  const { successCount, failedItems } = await installReposBatch(uniqueRepos, {
    ignoreVersionCheck,
  });

  loading_.value = false;

  // rest of the success/partial/failure UI handling stays the same
};
```

These changes keep all existing functionality but make the share-code logic more focused and easier to maintain.
</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.

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

This PR introduces a new plugin sharing feature, enabling users to easily share their plugin combinations. The implementation covers the full process from generating share codes using lz-string to bulk installation via imported codes, with well-integrated UI changes. However, a critical security concern has been identified: a potential Cross-Site Scripting (XSS) vulnerability in the ExtensionCard.vue component. This occurs because a repository URL is used in an href attribute without proper sanitization, which could be exploited by malicious plugins or share codes. Additionally, the code review highlighted several areas for improvement in code quality and maintainability, including suggestions to remove unnecessary async keywords, reduce code duplication, minimize side effects by avoiding shared state modification, and address a minor UI color inconsistency.

…selection UX

make share-code helpers synchronous and reduce unnecessary await usage

centralize share selection Set updates and keep behavior unchanged

parallelize share-code imports with Promise.allSettled and keep progress updates

align share-mode selected card glow with primary color and adjust user-select behavior
@whatevertogo
Copy link
Contributor

#5771

@united-pooh
Copy link
Author

Code Review  代码审查

This PR introduces a new plugin sharing feature, enabling users to easily share their plugin combinations. The implementation covers the full process from generating share codes using lz-string to bulk installation via imported codes, with well-integrated UI changes. However, a critical security concern has been identified: a potential Cross-Site Scripting (XSS) vulnerability in the ExtensionCard.vue component. This occurs because a repository URL is used in an href attribute without proper sanitization, which could be exploited by malicious plugins or share codes. Additionally, the code review highlighted several areas for improvement in code quality and maintainability, including suggestions to remove unnecessary async keywords, reduce code duplication, minimize side effects by avoiding shared state modification, and address a minor UI color inconsistency.这个 PR 引入了新的插件分享功能,使用户能够轻松分享他们的插件组合。实现涵盖了从使用 lz-string 生成分享码到通过导入码进行批量安装的完整流程,并集成了良好的 UI 变更。然而,已发现一个关键的安全问题: ExtensionCard.vue 组件中存在潜在的跨站脚本(XSS)漏洞。这是因为一个仓库 URL 被用于 href 属性而没有进行适当的清理,这可能被恶意插件或分享码利用。此外,代码审查指出了代码质量和可维护性方面的几个改进领域,包括建议移除不必要的 async 关键字、减少代码重复、通过避免修改共享状态来最小化副作用,以及解决一个轻微的 UI 颜色不一致问题。

已完成部分优化

@united-pooh united-pooh marked this pull request as draft March 7, 2026 14:23
@united-pooh united-pooh marked this pull request as ready for review March 7, 2026 14:23
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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 个问题

供 AI 代理使用的提示词
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="135" />
<code_context>
     return false;
   };
   const showReserved = ref(getInitialShowReserved());
+  const isShareMode = ref(false);
+  const selectedSharePluginNames = ref(new Set());
+  const cancelShareMode = () => {
</code_context>
<issue_to_address>
**issue (complexity):** 请考虑将新的分享选择、分享代码以及批量安装逻辑抽取到独立的 composable / helper 中,这样 `useExtensionPage` 只需要协调高层行为,而不必处理底层细节。

新增的分享和批量安装逻辑显著增加了 `useExtensionPage` 的职责。你可以在保留现有行为的前提下,通过抽取几个聚焦明确的 helper/composable 并隐藏底层细节来降低复杂度。

### 1. 将分享选择逻辑抽取为一个小的 helper

所有与 `Set` 相关的管理逻辑都可以被封装起来,让页面只使用一个小而清晰的 API:

```ts
// useSelectionSet.ts
import { ref, computed } from "vue";

export const useSelectionSet = () => {
  const selected = ref(new Set<string>());

  const clear = () => { selected.value = new Set(); };

  const toggle = (name?: string) => {
    if (!name) return;
    const next = new Set(selected.value);
    if (next.has(name)) next.delete(name);
    else next.add(name);
    selected.value = next;
  };

  const toggleAll = (items: string[]) => {
    const names = items.filter(Boolean);
    if (!names.length) return;
    const allSelected = names.every((n) => selected.value.has(n));
    const next = new Set(selected.value);
    if (allSelected) names.forEach((n) => next.delete(n));
    else names.forEach((n) => next.add(n));
    selected.value = next;
  };

  const areAllSelected = (items: string[]) =>
    items.length > 0 && items.every((n) => selected.value.has(n));

  const isSelected = (name?: string) =>
    !!name && selected.value.has(name);

  const count = computed(() => selected.value.size);

  return {
    selected,
    clear,
    toggle,
    toggleAll,
    areAllSelected,
    isSelected,
    count,
  };
};
```

然后在 `useExtensionPage` 中,你可以收窄与分享相关的接口面:

```ts
const isShareMode = ref(false);
const {
  selected: selectedSharePluginNames,
  clear: clearShareSelection,
  toggle: toggleSharePluginSelection,
  toggleAll: toggleShareSelectAll,
  areAllSelected: areAllSharePluginsSelected,
  isSelected: isSharePluginSelected,
  count: selectedSharePluginCount,
} = useSelectionSet();

const cancelShareMode = () => {
  isShareMode.value = false;
  clearShareSelection();
};

const toggleShareMode = () => {
  if (isShareMode.value) {
    cancelShareMode();
  } else {
    isShareMode.value = true;
    clearShareSelection();
  }
};
```

这样你就可以移除 `normalizePluginNames``updateSelectedSharePlugins`,并减少分享选择相关 helper 的数量。

### 2. 收拢分享代码的编码 / 解码 / 解析逻辑

与其让 `encodeShareCode``decodeShareCode``parseShareCodeRepos` 分散在整个 composable 中,不如将它们集中起来:

```ts
// useExtensionShareCode.ts
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";

const SHARE_CODE_LZ_PREFIX = "astrbot-share:";

export const useExtensionShareCode = () => {
  const encode = (repos: string[]) => {
    const normalized = [...new Set(
      (repos || [])
        .filter((r) => typeof r === "string")
        .map((r) => r.trim())
        .filter(Boolean),
    )].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));

    const rawJson = JSON.stringify({ repos: normalized });
    const compressed = compressToEncodedURIComponent(rawJson);
    if (!compressed) return rawJson;
    return `${SHARE_CODE_LZ_PREFIX}${compressed}`;
  };

  const decode = (code: string): string[] => {
    const raw = code.startsWith(SHARE_CODE_LZ_PREFIX)
      ? (() => {
          const encoded = code.slice(SHARE_CODE_LZ_PREFIX.length);
          const decompressed = decompressFromEncodedURIComponent(encoded);
          if (!decompressed) throw new Error("Invalid lz share code");
          return decompressed;
        })()
      : code;

    const parsed = JSON.parse(raw);
    const repos = Array.isArray(parsed?.repos) ? parsed.repos : [];
    return repos
      .filter((r) => typeof r === "string")
      .map((r) => r.trim())
      .filter(Boolean);
  };

  return { encode, decode };
};
```

这样在 `useExtensionPage` 中的使用就会更简单,并且把 JSON / 压缩相关的细节从主要的 composable 中移走:

```ts
const { encode: encodeShareCode, decode: decodeShareCode } = useExtensionShareCode();

const confirmShareSelection = async () => {
  const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
  const selectedNames = selectedSharePluginNames.value;
  const repos = data
    .filter((ext) => selectedNames.has(ext.name))
    .map((ext) => (ext.repo ?? "").trim());

  const shareCode = encodeShareCode(repos);
  // clipboard + toast logic unchanged
};

const installFromShareCode = async (ignoreVersionCheck = false) => {
  const rawCode = (shareCodeInput.value ?? "").trim();
  if (!rawCode) {
    toast(tm("messages.fillShareCode"), "error");
    return;
  }

  let repos: string[];
  try {
    repos = decodeShareCode(rawCode);
  } catch {
    toast(tm("messages.invalidShareCode"), "error");
    return;
  }

  // continue with batch install using the repos
};
```

这可以完全去掉 `parseShareCodeRepos`,并消除重复的仓库地址归一化逻辑。

### 3. 将批量安装逻辑与 UI / 进度解耦

你可以让 `installReposBatch` 保持纯逻辑实现,并把 `tm``resolveErrorMessage` 和进度弹窗相关的内容移动到调用方:

```ts
// batchInstall.ts
export type BatchResult = {
  successCount: number;
  failedItems: { url: string; message: string }[];
};

export const installReposBatch = async (
  repos: string[],
  {
    ignoreVersionCheck,
    performInstall,
    onProgress,
  }: {
    ignoreVersionCheck: boolean;
    performInstall: (url: string) => Promise<{ ok: boolean; message?: string }>;
    onProgress?: (info: { current: number; total: number; repoUrl: string }) => void;
  },
): Promise<BatchResult> => {
  let completedCount = 0;
  const tasks = repos.map(async (repoUrl) => {
    try {
      const res = await performInstall(repoUrl);
      return { success: res.ok, url: repoUrl, message: res.message };
    } finally {
      completedCount += 1;
      onProgress?.({ current: completedCount, total: repos.length, repoUrl });
    }
  });

  const settled = await Promise.allSettled(tasks);
  let successCount = 0;
  const failedItems: { url: string; message: string }[] = [];

  settled.forEach((r) => {
    if (r.status === "fulfilled") {
      if (r.value.success) successCount += 1;
      else failedItems.push({ url: r.value.url, message: r.value.message ?? "" });
    } else {
      failedItems.push({ url: "", message: "" }); // caller can decorate errors
    }
  });

  return { successCount, failedItems };
};
```

然后在 `useExtensionPage` 中,你可以通过一个小的封装函数来适配现有的 `performInstallRequest``tm` 和弹窗状态,而不是把所有东西都塞进 `installReposBatch````ts
const performInstallUrl = async (repoUrl: string) => {
  try {
    const res = await performInstallRequest({
      source: "url",
      ignoreVersionCheck,
      url: repoUrl,
    });
    const resData = res.data || {};
    return {
      ok: resData.status === "ok",
      message: resData.message || tm("messages.installFailed"),
    };
  } catch (err) {
    return {
      ok: false,
      message: resolveErrorMessage(err, tm("messages.installFailed")),
    };
  }
};

const { successCount, failedItems } = await installReposBatch(uniqueRepos, {
  ignoreVersionCheck,
  performInstall: performInstallUrl,
  onProgress: ({ current, total, repoUrl }) => {
    loadingDialog.progressCurrent = current;
    loadingDialog.progressTotal = total;
    loadingDialog.progressLabel = repoUrl;
  },
});
```

这样可以让 `installReposBatch` 保持可复用、易测试,而 `installFromShareCode` 只需要表达高层流程(准备仓库 → 展示弹窗 → 调用批量 helper → 更新 UI),而不必关心内部的 HTTP / 错误细节。

这些抽取操作在保留全部行为的同时,让 `useExtensionPage` 不再那么臃肿,更易于维护。
</issue_to_address>

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

Hey - I've found 1 issue

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="135" />
<code_context>
     return false;
   };
   const showReserved = ref(getInitialShowReserved());
+  const isShareMode = ref(false);
+  const selectedSharePluginNames = ref(new Set());
+  const cancelShareMode = () => {
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the new share-selection, share-code, and batch-install logic into dedicated composables/helpers so `useExtensionPage` only coordinates high-level behavior instead of managing low-level details.

The added share + batch-install logic significantly increases `useExtensionPage`’s responsibilities. You can keep all behavior while reducing complexity by extracting a few focused helpers/composables and hiding low-level details.

### 1. Extract share-selection into a small helper

All the `Set`-management logic can be encapsulated so the page just uses a small API:

```ts
// useSelectionSet.ts
import { ref, computed } from "vue";

export const useSelectionSet = () => {
  const selected = ref(new Set<string>());

  const clear = () => { selected.value = new Set(); };

  const toggle = (name?: string) => {
    if (!name) return;
    const next = new Set(selected.value);
    if (next.has(name)) next.delete(name);
    else next.add(name);
    selected.value = next;
  };

  const toggleAll = (items: string[]) => {
    const names = items.filter(Boolean);
    if (!names.length) return;
    const allSelected = names.every((n) => selected.value.has(n));
    const next = new Set(selected.value);
    if (allSelected) names.forEach((n) => next.delete(n));
    else names.forEach((n) => next.add(n));
    selected.value = next;
  };

  const areAllSelected = (items: string[]) =>
    items.length > 0 && items.every((n) => selected.value.has(n));

  const isSelected = (name?: string) =>
    !!name && selected.value.has(name);

  const count = computed(() => selected.value.size);

  return {
    selected,
    clear,
    toggle,
    toggleAll,
    areAllSelected,
    isSelected,
    count,
  };
};
```

Then in `useExtensionPage` you can thin the share-related surface:

```ts
const isShareMode = ref(false);
const {
  selected: selectedSharePluginNames,
  clear: clearShareSelection,
  toggle: toggleSharePluginSelection,
  toggleAll: toggleShareSelectAll,
  areAllSelected: areAllSharePluginsSelected,
  isSelected: isSharePluginSelected,
  count: selectedSharePluginCount,
} = useSelectionSet();

const cancelShareMode = () => {
  isShareMode.value = false;
  clearShareSelection();
};

const toggleShareMode = () => {
  if (isShareMode.value) {
    cancelShareMode();
  } else {
    isShareMode.value = true;
    clearShareSelection();
  }
};
```

You can now drop `normalizePluginNames`, `updateSelectedSharePlugins`, and reduce the number of share-selection helpers.

### 2. Consolidate share-code encode/decode/parse

Instead of `encodeShareCode`, `decodeShareCode`, and `parseShareCodeRepos` spread across the composable, you can centralize them:

```ts
// useExtensionShareCode.ts
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";

const SHARE_CODE_LZ_PREFIX = "astrbot-share:";

export const useExtensionShareCode = () => {
  const encode = (repos: string[]) => {
    const normalized = [...new Set(
      (repos || [])
        .filter((r) => typeof r === "string")
        .map((r) => r.trim())
        .filter(Boolean),
    )].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));

    const rawJson = JSON.stringify({ repos: normalized });
    const compressed = compressToEncodedURIComponent(rawJson);
    if (!compressed) return rawJson;
    return `${SHARE_CODE_LZ_PREFIX}${compressed}`;
  };

  const decode = (code: string): string[] => {
    const raw = code.startsWith(SHARE_CODE_LZ_PREFIX)
      ? (() => {
          const encoded = code.slice(SHARE_CODE_LZ_PREFIX.length);
          const decompressed = decompressFromEncodedURIComponent(encoded);
          if (!decompressed) throw new Error("Invalid lz share code");
          return decompressed;
        })()
      : code;

    const parsed = JSON.parse(raw);
    const repos = Array.isArray(parsed?.repos) ? parsed.repos : [];
    return repos
      .filter((r) => typeof r === "string")
      .map((r) => r.trim())
      .filter(Boolean);
  };

  return { encode, decode };
};
```

Usage in `useExtensionPage` becomes simpler and keeps JSON/compression details out of the main composable:

```ts
const { encode: encodeShareCode, decode: decodeShareCode } = useExtensionShareCode();

const confirmShareSelection = async () => {
  const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
  const selectedNames = selectedSharePluginNames.value;
  const repos = data
    .filter((ext) => selectedNames.has(ext.name))
    .map((ext) => (ext.repo ?? "").trim());

  const shareCode = encodeShareCode(repos);
  // clipboard + toast logic unchanged
};

const installFromShareCode = async (ignoreVersionCheck = false) => {
  const rawCode = (shareCodeInput.value ?? "").trim();
  if (!rawCode) {
    toast(tm("messages.fillShareCode"), "error");
    return;
  }

  let repos: string[];
  try {
    repos = decodeShareCode(rawCode);
  } catch {
    toast(tm("messages.invalidShareCode"), "error");
    return;
  }

  // continue with batch install using the repos
};
```

This eliminates `parseShareCodeRepos` entirely and removes duplicated repo normalization.

### 3. Decouple batch install logic from UI/progress

You can keep `installReposBatch` pure and move `tm`, `resolveErrorMessage`, and progress dialog concerns into the caller:

```ts
// batchInstall.ts
export type BatchResult = {
  successCount: number;
  failedItems: { url: string; message: string }[];
};

export const installReposBatch = async (
  repos: string[],
  {
    ignoreVersionCheck,
    performInstall,
    onProgress,
  }: {
    ignoreVersionCheck: boolean;
    performInstall: (url: string) => Promise<{ ok: boolean; message?: string }>;
    onProgress?: (info: { current: number; total: number; repoUrl: string }) => void;
  },
): Promise<BatchResult> => {
  let completedCount = 0;
  const tasks = repos.map(async (repoUrl) => {
    try {
      const res = await performInstall(repoUrl);
      return { success: res.ok, url: repoUrl, message: res.message };
    } finally {
      completedCount += 1;
      onProgress?.({ current: completedCount, total: repos.length, repoUrl });
    }
  });

  const settled = await Promise.allSettled(tasks);
  let successCount = 0;
  const failedItems: { url: string; message: string }[] = [];

  settled.forEach((r) => {
    if (r.status === "fulfilled") {
      if (r.value.success) successCount += 1;
      else failedItems.push({ url: r.value.url, message: r.value.message ?? "" });
    } else {
      failedItems.push({ url: "", message: "" }); // caller can decorate errors
    }
  });

  return { successCount, failedItems };
};
```

Then, in `useExtensionPage`, you adapt to your existing `performInstallRequest`, `tm`, and dialog state in a small wrapper, instead of embedding everything inside `installReposBatch`:

```ts
const performInstallUrl = async (repoUrl: string) => {
  try {
    const res = await performInstallRequest({
      source: "url",
      ignoreVersionCheck,
      url: repoUrl,
    });
    const resData = res.data || {};
    return {
      ok: resData.status === "ok",
      message: resData.message || tm("messages.installFailed"),
    };
  } catch (err) {
    return {
      ok: false,
      message: resolveErrorMessage(err, tm("messages.installFailed")),
    };
  }
};

const { successCount, failedItems } = await installReposBatch(uniqueRepos, {
  ignoreVersionCheck,
  performInstall: performInstallUrl,
  onProgress: ({ current, total, repoUrl }) => {
    loadingDialog.progressCurrent = current;
    loadingDialog.progressTotal = total;
    loadingDialog.progressLabel = repoUrl;
  },
});
```

This keeps `installReposBatch` reusable and testable, and `installFromShareCode` expresses only the high-level flow (prepare repos → show dialog → call batch helper → update UI), not the internal HTTP/error details.

These extractions preserve all behavior while making `useExtensionPage` less monolithic and easier to maintain.
</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.

@zouyonghe
Copy link
Member

@sourcery-ai review

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 个问题,并留下了一些总体反馈:

  • share-code 逻辑(SHARE_CODE_LZ_PREFIX、encode/decode、parseShareCodeRepos)在 useExtensionPage 里体量比较大;可以考虑把这部分提取成一个小的工具模块,并在负载中加入一个版本字段,以便未来调整格式时更容易,同时让这个 composable 更专注于 UI/状态。
  • 在有高亮文本时跳过选中切换的那层保护(window.getSelection()?.toString())在卡片和表格行点击处理里都重复出现了;你可以把它提取成一个共享的 helper,避免不同视图之间出现细微的行为偏差。
  • installReposBatch 目前会并行发送所有安装请求,对于较大的 share code 可能会给后端造成压力;可以考虑增加一个简单的并发上限,或者改成顺序执行,以更好地控制负载和进度反馈。
给 AI Agents 的提示词
请根据这次代码评审中的评论进行修改:

## 整体评论
- share-code 逻辑(SHARE_CODE_LZ_PREFIX、encode/decode、parseShareCodeRepos)在 useExtensionPage 里体量比较大;可以考虑把这部分提取成一个小的工具模块,并在负载中加入一个版本字段,以便未来调整格式时更容易,同时让这个 composable 更专注于 UI/状态。
- 在有高亮文本时跳过选中切换的那层保护(window.getSelection()?.toString())在卡片和表格行点击处理里都重复出现了;你可以把它提取成一个共享的 helper,避免不同视图之间出现细微的行为偏差。
- installReposBatch 目前会并行发送所有安装请求,对于较大的 share code 可能会给后端造成压力;可以考虑增加一个简单的并发上限,或者改成顺序执行,以更好地控制负载和进度反馈。

## 单条评论

### 评论 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="135" />
<code_context>
     return false;
   };
   const showReserved = ref(getInitialShowReserved());
+  const isShareMode = ref(false);
+  const selectedSharePluginNames = ref(new Set());
+  const cancelShareMode = () => {
</code_context>
<issue_to_address>
**issue (complexity):** 可以考虑把新的 share-selection、share-code 和 loading-dialog 进度相关的逻辑重构到更聚焦的 helper/工具中,在保留行为的前提下,让 `useExtensionPage` 更小、更容易快速浏览。

你可以保留所有新增行为,只是通过把强相关的状态和逻辑分组、以及简化几个 helper,来稍微降低一下复杂度。

### 1. 把 share-selection 状态收拢到一个小 helper 中

当前 `isShareMode``selectedSharePluginNames``toggleShareMode``cancelShareMode``toggleSharePluginSelection``toggleShareSelectAll``areAllSharePluginsSelected``isSharePluginSelected``selectedSharePluginCount``normalizePluginNames` 都是在 `useExtensionPage` 顶层定义的。

你可以把它们放到一个小的内部 helper/composable 中,在不改变行为的前提下缩小主函数的“表面积”:

```js
// inside useExtensionPage.js
const useShareSelection = (toast, tm, extension_data) => {
  const isShareMode = ref(false);
  const selectedNames = ref(new Set());

  const reset = () => {
    isShareMode.value = false;
    selectedNames.value = new Set();
  };

  const toggleMode = () => {
    if (isShareMode.value) {
      reset();
    } else {
      isShareMode.value = true;
      selectedNames.value = new Set();
    }
  };

  const normalizeNames = (names = []) =>
    names.filter((name) => typeof name === "string" && name.length > 0);

  const togglePlugin = (pluginName) => {
    if (!isShareMode.value || !pluginName) return;
    const next = new Set(selectedNames.value);
    next.has(pluginName) ? next.delete(pluginName) : next.add(pluginName);
    selectedNames.value = next;
  };

  const toggleAll = (pluginNames = []) => {
    if (!isShareMode.value) return;
    const names = normalizeNames(pluginNames);
    if (names.length === 0) return;

    const allSelected = names.every((name) => selectedNames.value.has(name));
    const next = new Set(selectedNames.value);
    if (allSelected) {
      names.forEach((name) => next.delete(name));
    } else {
      names.forEach((name) => next.add(name));
    }
    selectedNames.value = next;
  };

  const allSelected = (pluginNames = []) => {
    const names = normalizeNames(pluginNames);
    if (names.length === 0) return false;
    return names.every((name) => selectedNames.value.has(name));
  };

  const isSelected = (pluginName) =>
    !!pluginName && selectedNames.value.has(pluginName);

  const selectedCount = computed(() => selectedNames.value.size);

  const confirmShareSelection = async () => {
    const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
    const repos = data
      .filter((ext) => selectedNames.value.has(ext.name))
      .map((ext) => (ext.repo ?? "").trim())
      .filter((r) => r.length > 0);
    const uniqueRepos = [...new Set(repos)].sort((a, b) =>
      a.localeCompare(b, undefined, { sensitivity: "base" }),
    );

    if (uniqueRepos.length === 0) {
      toast(tm("messages.noShareableRepos"), "warning");
      reset();
      return;
    }

    // reuse your existing encodeShareCode here
    const shareCode = encodeShareCode({ repos: uniqueRepos });
    try {
      if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
        throw new Error("Clipboard API unavailable");
      }
      await navigator.clipboard.writeText(shareCode);
      toast(tm("messages.shareCodeCopied"), "success");
    } catch (error) {
      toast(`${tm("messages.shareCodeCopyFailed")} ${shareCode}`, "warning");
      console.error("Failed to copy share code:", error);
    } finally {
      reset();
    }
  };

  return {
    isShareMode,
    selectedNames,
    selectedCount,
    toggleMode,
    reset,
    togglePlugin,
    toggleAll,
    allSelected,
    isSelected,
    confirmShareSelection,
  };
};
```

然后在 `useExtensionPage` 中只需要暴露一小块内聚的接口:

```js
const {
  isShareMode,
  selectedNames: selectedSharePluginNames,
  selectedCount: selectedSharePluginCount,
  toggleMode: toggleShareMode,
  reset: cancelShareMode,
  togglePlugin: toggleSharePluginSelection,
  toggleAll: toggleShareSelectAll,
  allSelected: areAllSharePluginsSelected,
  isSelected: isSharePluginSelected,
  confirmShareSelection,
} = useShareSelection(toast, tm, extension_data);
```

这样也就不再需要 `updateSelectedSharePlugins` 了,因为 `Set` 的克隆逻辑已经在 `togglePlugin` / `toggleAll` 中被局部化并显式处理了。

### 2. 将 share-code 协议抽取为一个工具

`encodeShareCode``decodeShareCode``parseShareCodeRepos` 一起定义了你的“share code 格式”。把这部分逻辑放在 view composable 中会让 `useExtensionPage` 更长、更难快速浏览。

你可以把这个“协议”迁移到一个小工具模块中,同时保持现有行为:

```js
// src/utils/shareCode.js
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";

const SHARE_CODE_LZ_PREFIX = "astrbot-share:";

export const encodeShareCode = (payload) => {
  const rawJson = JSON.stringify(payload);
  const compressed = compressToEncodedURIComponent(rawJson);
  if (!compressed) return rawJson;
  return `${SHARE_CODE_LZ_PREFIX}${compressed}`;
};

export const decodeShareCode = (rawCode) => {
  if (rawCode.startsWith(SHARE_CODE_LZ_PREFIX)) {
    const encodedPart = rawCode.slice(SHARE_CODE_LZ_PREFIX.length);
    const decompressed = decompressFromEncodedURIComponent(encodedPart);
    if (!decompressed) throw new Error("Invalid lz share code");
    return decompressed;
  }
  return rawCode;
};

export const parseShareCodeRepos = (rawCode) => {
  const decodedContent = decodeShareCode(rawCode);
  const parsed = JSON.parse(decodedContent);
  const repos = Array.isArray(parsed?.repos) ? parsed.repos : [];
  return repos
    .filter((repo) => typeof repo === "string")
    .map((repo) => repo.trim())
    .filter((repo) => repo.length > 0);
};
```

然后 `useExtensionPage` 中只需要:

```js
import { encodeShareCode, parseShareCodeRepos } from "@/utils/shareCode";

// ... 在 confirmShareSelection 中使用 encodeShareCode
// ... 在 installFromShareCode 中使用 parseShareCodeRepos
```

这会让 `useExtensionPage` 更精简,同时把协议层面的改动集中到一个地方。

### 3. 将 loading dialog 的进度结构化为嵌套对象

`loadingDialog` 上的四个进度字段以及它们的重置路径增加了比较分散的可变状态。你可以保持 UI 完全不变,只是用一个嵌套的 `progress` 对象和一个工厂函数来管理:

```js
const defaultProgress = () => ({
  enabled: false,
  current: 0,
  total: 0,
  label: "",
});

const loadingDialog = reactive({
  show: false,
  title: "",
  statusCode: 0,
  result: "",
  progress: defaultProgress(),
});

const resetLoadingDialog = () => {
  Object.assign(loadingDialog, {
    show: false,
    title: tm("dialogs.loading.title"),
    statusCode: 0,
    result: "",
    progress: defaultProgress(),
  });
};

const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
  loadingDialog.statusCode = statusCode;
  loadingDialog.result = result;
  loadingDialog.progress.enabled = false;
  if (timeToClose === -1) return;
  setTimeout(resetLoadingDialog, timeToClose);
};
```

并更新批量安装时的使用方式:

```js
loadingDialog.progress.enabled = true;
loadingDialog.progress.current = 0;
loadingDialog.progress.total = uniqueRepos.length;
loadingDialog.progress.label = "";

onProgress: ({ current, total, repoUrl }) => {
  loadingDialog.progress.current = current;
  loadingDialog.progress.total = total;
  loadingDialog.progress.label = repoUrl;
},
```

这样可以保持现有行为,同时让响应式状态的“表面积”更小,并把重置逻辑集中到一个地方。
</issue_to_address>

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

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

  • The share-code logic (SHARE_CODE_LZ_PREFIX, encode/decode, parseShareCodeRepos) is fairly substantial inside useExtensionPage; consider extracting it into a small utility module and adding a version field to the payload to make future format changes easier and keep the composable focused on UI/state.
  • The guard that skips selection toggling when there is highlighted text (window.getSelection()?.toString()) is duplicated in both card and table row handlers; you could extract this into a shared helper to avoid subtle behavior drift between views.
  • installReposBatch currently fires all install requests in parallel, which could stress the backend for large share codes; consider adding a simple concurrency limit or sequential execution to better control load and progress reporting.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The share-code logic (SHARE_CODE_LZ_PREFIX, encode/decode, parseShareCodeRepos) is fairly substantial inside useExtensionPage; consider extracting it into a small utility module and adding a version field to the payload to make future format changes easier and keep the composable focused on UI/state.
- The guard that skips selection toggling when there is highlighted text (window.getSelection()?.toString()) is duplicated in both card and table row handlers; you could extract this into a shared helper to avoid subtle behavior drift between views.
- installReposBatch currently fires all install requests in parallel, which could stress the backend for large share codes; consider adding a simple concurrency limit or sequential execution to better control load and progress reporting.

## Individual Comments

### Comment 1
<location path="dashboard/src/views/extension/useExtensionPage.js" line_range="135" />
<code_context>
     return false;
   };
   const showReserved = ref(getInitialShowReserved());
+  const isShareMode = ref(false);
+  const selectedSharePluginNames = ref(new Set());
+  const cancelShareMode = () => {
</code_context>
<issue_to_address>
**issue (complexity):** Consider refactoring the new share-selection, share-code, and loading-dialog progress logic into focused helpers/utilities to keep `useExtensionPage` smaller and easier to scan while preserving behavior.

You can keep all the new behavior but reduce complexity a bit by grouping tightly-related state and logic and simplifying a couple of helpers.

### 1. Group share-selection state into a small helper

Right now `isShareMode`, `selectedSharePluginNames`, `toggleShareMode`, `cancelShareMode`, `toggleSharePluginSelection`, `toggleShareSelectAll`, `areAllSharePluginsSelected`, `isSharePluginSelected`, `selectedSharePluginCount` and `normalizePluginNames` are all top-level in `useExtensionPage`.

You can move them into a small internal helper/composable to reduce the surface area of the main function, without changing behavior:

```js
// inside useExtensionPage.js
const useShareSelection = (toast, tm, extension_data) => {
  const isShareMode = ref(false);
  const selectedNames = ref(new Set());

  const reset = () => {
    isShareMode.value = false;
    selectedNames.value = new Set();
  };

  const toggleMode = () => {
    if (isShareMode.value) {
      reset();
    } else {
      isShareMode.value = true;
      selectedNames.value = new Set();
    }
  };

  const normalizeNames = (names = []) =>
    names.filter((name) => typeof name === "string" && name.length > 0);

  const togglePlugin = (pluginName) => {
    if (!isShareMode.value || !pluginName) return;
    const next = new Set(selectedNames.value);
    next.has(pluginName) ? next.delete(pluginName) : next.add(pluginName);
    selectedNames.value = next;
  };

  const toggleAll = (pluginNames = []) => {
    if (!isShareMode.value) return;
    const names = normalizeNames(pluginNames);
    if (names.length === 0) return;

    const allSelected = names.every((name) => selectedNames.value.has(name));
    const next = new Set(selectedNames.value);
    if (allSelected) {
      names.forEach((name) => next.delete(name));
    } else {
      names.forEach((name) => next.add(name));
    }
    selectedNames.value = next;
  };

  const allSelected = (pluginNames = []) => {
    const names = normalizeNames(pluginNames);
    if (names.length === 0) return false;
    return names.every((name) => selectedNames.value.has(name));
  };

  const isSelected = (pluginName) =>
    !!pluginName && selectedNames.value.has(pluginName);

  const selectedCount = computed(() => selectedNames.value.size);

  const confirmShareSelection = async () => {
    const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
    const repos = data
      .filter((ext) => selectedNames.value.has(ext.name))
      .map((ext) => (ext.repo ?? "").trim())
      .filter((r) => r.length > 0);
    const uniqueRepos = [...new Set(repos)].sort((a, b) =>
      a.localeCompare(b, undefined, { sensitivity: "base" }),
    );

    if (uniqueRepos.length === 0) {
      toast(tm("messages.noShareableRepos"), "warning");
      reset();
      return;
    }

    // reuse your existing encodeShareCode here
    const shareCode = encodeShareCode({ repos: uniqueRepos });
    try {
      if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
        throw new Error("Clipboard API unavailable");
      }
      await navigator.clipboard.writeText(shareCode);
      toast(tm("messages.shareCodeCopied"), "success");
    } catch (error) {
      toast(`${tm("messages.shareCodeCopyFailed")} ${shareCode}`, "warning");
      console.error("Failed to copy share code:", error);
    } finally {
      reset();
    }
  };

  return {
    isShareMode,
    selectedNames,
    selectedCount,
    toggleMode,
    reset,
    togglePlugin,
    toggleAll,
    allSelected,
    isSelected,
    confirmShareSelection,
  };
};
```

Then in `useExtensionPage` you only expose a small, coherent block:

```js
const {
  isShareMode,
  selectedNames: selectedSharePluginNames,
  selectedCount: selectedSharePluginCount,
  toggleMode: toggleShareMode,
  reset: cancelShareMode,
  togglePlugin: toggleSharePluginSelection,
  toggleAll: toggleShareSelectAll,
  allSelected: areAllSharePluginsSelected,
  isSelected: isSharePluginSelected,
  confirmShareSelection,
} = useShareSelection(toast, tm, extension_data);
```

This also makes `updateSelectedSharePlugins` unnecessary, since the `Set` cloning logic is localised and explicit in `togglePlugin` / `toggleAll`.

### 2. Extract share-code protocol into a utility

`encodeShareCode`, `decodeShareCode`, `parseShareCodeRepos` together define your “share code format”. Keeping that logic in the view composable makes `useExtensionPage` longer and harder to scan.

You can move the protocol into a small utility while preserving all behavior:

```js
// src/utils/shareCode.js
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from "lz-string";

const SHARE_CODE_LZ_PREFIX = "astrbot-share:";

export const encodeShareCode = (payload) => {
  const rawJson = JSON.stringify(payload);
  const compressed = compressToEncodedURIComponent(rawJson);
  if (!compressed) return rawJson;
  return `${SHARE_CODE_LZ_PREFIX}${compressed}`;
};

export const decodeShareCode = (rawCode) => {
  if (rawCode.startsWith(SHARE_CODE_LZ_PREFIX)) {
    const encodedPart = rawCode.slice(SHARE_CODE_LZ_PREFIX.length);
    const decompressed = decompressFromEncodedURIComponent(encodedPart);
    if (!decompressed) throw new Error("Invalid lz share code");
    return decompressed;
  }
  return rawCode;
};

export const parseShareCodeRepos = (rawCode) => {
  const decodedContent = decodeShareCode(rawCode);
  const parsed = JSON.parse(decodedContent);
  const repos = Array.isArray(parsed?.repos) ? parsed.repos : [];
  return repos
    .filter((repo) => typeof repo === "string")
    .map((repo) => repo.trim())
    .filter((repo) => repo.length > 0);
};
```

Then `useExtensionPage` just does:

```js
import { encodeShareCode, parseShareCodeRepos } from "@/utils/shareCode";

// ... use encodeShareCode in confirmShareSelection
// ... use parseShareCodeRepos in installFromShareCode
```

This shrinks `useExtensionPage` and isolates protocol changes to one place.

### 3. Structure loading dialog progress as a nested object

The four progress fields on `loadingDialog` and their reset paths add more scattered mutable state. You can keep the UI exactly the same with a nested `progress` object and a factory:

```js
const defaultProgress = () => ({
  enabled: false,
  current: 0,
  total: 0,
  label: "",
});

const loadingDialog = reactive({
  show: false,
  title: "",
  statusCode: 0,
  result: "",
  progress: defaultProgress(),
});

const resetLoadingDialog = () => {
  Object.assign(loadingDialog, {
    show: false,
    title: tm("dialogs.loading.title"),
    statusCode: 0,
    result: "",
    progress: defaultProgress(),
  });
};

const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
  loadingDialog.statusCode = statusCode;
  loadingDialog.result = result;
  loadingDialog.progress.enabled = false;
  if (timeToClose === -1) return;
  setTimeout(resetLoadingDialog, timeToClose);
};
```

And update the batch install usage:

```js
loadingDialog.progress.enabled = true;
loadingDialog.progress.current = 0;
loadingDialog.progress.total = uniqueRepos.length;
loadingDialog.progress.label = "";

onProgress: ({ current, total, repoUrl }) => {
  loadingDialog.progress.current = current;
  loadingDialog.progress.total = total;
  loadingDialog.progress.label = repoUrl;
},
```

This keeps the behavior but makes the reactive surface smaller, and the reset logic centralized.
</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.

Copy link
Author

@united-pooh united-pooh left a comment

Choose a reason for hiding this comment

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

把按钮的配色与原有组件颜色进行统一,继承颜色的语义

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

Labels

feature:plugin The bug / feature is about AstrBot plugin system. size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

X Tutup