Files
AstrBot/dashboard/src/views/extension/useExtensionPage.js
ChuwuYo 795aec9578 feat(extension): add filtering and sorting for installed plugins in WebUI (#5923)
* feat(extension): add PluginSortControl reusable component for sorting

* i18n: add i18n keys for plugin sorting and filtering features

* feat(extension): add sorting and status filtering for installed plugins

Backend changes (plugin.py):
- Add _resolve_plugin_dir method to resolve plugin directory path
- Add _get_plugin_installed_at method to get installation time from file mtime
- Add installed_at field to plugin API response

Frontend changes (InstalledPluginsTab.vue):
- Import PluginSortControl component
- Add status filter toggle (all/enabled/disabled) using v-btn-toggle
- Integrate PluginSortControl for sorting options
- Add toolbar layout with actions and controls sections

Frontend changes (MarketPluginsTab.vue):
- Import PluginSortControl component
- Replace v-select + v-btn combination with unified PluginSortControl

Frontend changes (useExtensionPage.js):
- Add installedStatusFilter, installedSortBy, installedSortOrder refs
- Add installedSortItems and installedSortUsesOrder computed properties
- Add sortInstalledPlugins function with multi-criteria support
- Support sorting by install time, name, author, and update status
- Add status filtering in filteredPlugins computed property
- Disable default table sorting by setting sortable: false

* test: add tests for installed_at field in plugin API

- Assert all plugins have installed_at field in get_plugins response
- Assert installed_at is not null after plugin installation

* fix(extension): add explicit fallbacks for installed plugin sort comparisons

* i18n(extension): rename install time label to last modified

* fix(extension): cache installed_at parsing and validate timestamp format in tests

* test(dashboard): strengthen installed_at coverage for plugin API
2026-03-09 17:12:22 +09:00

1702 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from "axios";
import { useCommonStore } from "@/stores/common";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { getPlatformDisplayName } from "@/utils/platformUtils";
import { resolveErrorMessage } from "@/utils/errorUtils";
import {
buildSearchQuery,
matchesPluginSearch,
normalizeStr,
toInitials,
toPinyinText,
} from "@/utils/pluginSearch";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
const useRandomPluginsDisplay = ({ activeTab, marketSearch, currentPage }) => {
const showRandomPlugins = ref(true);
const toggleRandomPluginsVisibility = () => {
showRandomPlugins.value = !showRandomPlugins.value;
};
const collapseRandomPlugins = () => {
showRandomPlugins.value = false;
};
watch(marketSearch, () => {
if (activeTab.value === "market") {
collapseRandomPlugins();
}
});
watch(currentPage, (newPage, oldPage) => {
if (newPage === oldPage) return;
if (activeTab.value !== "market") return;
collapseRandomPlugins();
});
return {
showRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
};
};
const buildFailedPluginItems = (raw) => {
return Object.entries(raw || {}).map(([dirName, info]) => {
const detail = info && typeof info === "object" ? info : {};
return {
...detail,
dir_name: dirName,
name: detail.name || dirName,
display_name: detail.display_name || detail.name || dirName,
error: detail.error || "",
traceback: detail.traceback || "",
reserved: !!detail.reserved,
};
});
};
export const useExtensionPage = () => {
const commonStore = useCommonStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
const { width } = useDisplay();
const getSelectedGitHubProxy = () => {
if (typeof window === "undefined" || !window.localStorage) return "";
return localStorage.getItem("githubProxyRadioValue") === "1"
? localStorage.getItem("selectedGitHubProxy") || ""
: "";
};
// 检查指令冲突并提示
const conflictDialog = reactive({
show: false,
count: 0,
});
const checkAndPromptConflicts = async () => {
try {
const res = await axios.get("/api/commands");
if (res.data.status === "ok") {
const conflicts = res.data.data.summary?.conflicts || 0;
if (conflicts > 0) {
conflictDialog.count = conflicts;
conflictDialog.show = true;
}
}
} catch (err) {
console.debug("Failed to check command conflicts:", err);
}
};
const handleConflictConfirm = () => {
activeTab.value = "commands";
};
const fileInput = ref(null);
const activeTab = ref("installed");
const validTabs = ["installed", "market", "mcp", "skills", "components"];
const isValidTab = (tab) => validTabs.includes(tab);
const getLocationHash = () =>
typeof window !== "undefined" ? window.location.hash : "";
const extractTabFromHash = (hash) => {
const lastHashIndex = (hash || "").lastIndexOf("#");
if (lastHashIndex === -1) return "";
return hash.slice(lastHashIndex + 1);
};
const syncTabFromHash = (hash) => {
const tab = extractTabFromHash(hash);
if (isValidTab(tab)) {
activeTab.value = tab;
return true;
}
return false;
};
const extension_data = reactive({
data: [],
message: "",
});
// 从 localStorage 恢复显示系统插件的状态,默认为 false隐藏
const getInitialShowReserved = () => {
if (typeof window !== "undefined" && window.localStorage) {
const saved = localStorage.getItem("showReservedPlugins");
return saved === "true";
}
return false;
};
const showReserved = ref(getInitialShowReserved());
const snack_message = ref("");
const snack_show = ref(false);
const snack_success = ref("success");
const configDialog = ref(false);
const extension_config = reactive({
metadata: {},
config: {},
});
const pluginMarketData = ref([]);
const loadingDialog = reactive({
show: false,
title: "",
statusCode: 0, // 0: loading, 1: success, 2: error,
result: "",
});
const showPluginInfoDialog = ref(false);
const selectedPlugin = ref({});
const curr_namespace = ref("");
const updatingAll = ref(false);
const readmeDialog = reactive({
show: false,
pluginName: "",
repoUrl: null,
});
// 强制更新确认对话框
const forceUpdateDialog = reactive({
show: false,
extensionName: "",
});
// 更新全部插件确认对话框
const updateAllConfirmDialog = reactive({
show: false,
});
// 插件更新日志对话框(复用 ReadmeDialog
const changelogDialog = reactive({
show: false,
pluginName: "",
repoUrl: null,
});
// 新增变量支持列表视图
// 从 localStorage 恢复显示模式,默认为 false卡片视图
const getInitialListViewMode = () => {
if (typeof window !== "undefined" && window.localStorage) {
return localStorage.getItem("pluginListViewMode") === "true";
}
return false;
};
const isListView = ref(getInitialListViewMode());
const pluginSearch = ref("");
const installedStatusFilter = ref("all");
const installedSortBy = ref("default");
const installedSortOrder = ref("desc");
const loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
const selectedMarketInstallPlugin = ref(null);
const installCompat = reactive({
checked: false,
compatible: true,
message: "",
});
// AstrBot 版本范围不兼容警告对话框
const versionCompatibilityDialog = reactive({
show: false,
message: "",
});
// 卸载插件确认对话框(列表模式用)
const showUninstallDialog = ref(false);
const uninstallTarget = ref(null);
// 自定义插件源相关
const showSourceDialog = ref(false);
const showSourceManagerDialog = ref(false);
const sourceName = ref("");
const sourceUrl = ref("");
const customSources = ref([]);
const selectedSource = ref(null);
const showRemoveSourceDialog = ref(false);
const sourceToRemove = ref(null);
const editingSource = ref(false);
const originalSourceUrl = ref("");
// 插件市场相关
const extension_url = ref("");
const dialog = ref(false);
const upload_file = ref(null);
const uploadTab = ref("file");
const showPluginFullName = ref(false);
const marketSearch = ref("");
const debouncedMarketSearch = ref("");
const refreshingMarket = ref(false);
const sortBy = ref("default"); // default, stars, author, updated
const sortOrder = ref("desc"); // desc (降序) or asc (升序)
const randomPluginNames = ref([]);
const {
showRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
} = useRandomPluginsDisplay({
activeTab,
marketSearch,
currentPage,
});
// 插件市场拼音搜索
const plugin_handler_info_headers = computed(() => [
{ title: tm("table.headers.eventType"), key: "event_type_h" },
{ title: tm("table.headers.description"), key: "desc", maxWidth: "250px" },
{ title: tm("table.headers.specificType"), key: "type" },
{ title: tm("table.headers.trigger"), key: "cmd" },
]);
const installedSortItems = computed(() => [
{ title: tm("sort.default"), value: "default" },
{ title: tm("sort.installTime"), value: "install_time" },
{ title: tm("sort.name"), value: "name" },
{ title: tm("sort.author"), value: "author" },
{ title: tm("sort.updateStatus"), value: "update_status" },
]);
const installedSortUsesOrder = computed(
() => installedSortBy.value !== "default",
);
// 插件表格的表头定义
const showAuthorColumn = computed(() => width.value >= 1280);
const pluginHeaders = computed(() => {
const headers = [
{
title: tm("table.headers.name"),
key: "name",
sortable: false,
width: showAuthorColumn.value ? "24%" : "26%",
},
{
title: tm("table.headers.description"),
key: "desc",
sortable: false,
width: showAuthorColumn.value ? "32%" : "36%",
},
{
title: tm("table.headers.version"),
key: "version",
sortable: false,
width: showAuthorColumn.value ? "12%" : "14%",
},
];
if (showAuthorColumn.value) {
headers.push({
title: tm("table.headers.author"),
key: "author",
sortable: false,
width: "10%",
});
}
headers.push({
title: tm("table.headers.actions"),
key: "actions",
sortable: false,
width: showAuthorColumn.value ? "22%" : "24%",
});
return headers;
});
// 过滤要显示的插件
const filteredExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
if (!showReserved.value) {
return data.filter((ext) => !ext.reserved);
}
return data;
});
const compareInstalledPluginNames = (left, right) =>
normalizeStr(left?.name ?? "").localeCompare(
normalizeStr(right?.name ?? ""),
undefined,
{
sensitivity: "base",
},
);
const compareInstalledPluginAuthors = (left, right) =>
normalizeStr(left?.author ?? "").localeCompare(
normalizeStr(right?.author ?? ""),
undefined,
{ sensitivity: "base" },
);
const getInstalledAtTimestamp = (plugin) => {
const parsed = Date.parse(plugin?.installed_at ?? "");
return Number.isFinite(parsed) ? parsed : null;
};
const sortInstalledPlugins = (plugins) => {
return plugins
.map((plugin, index) => ({
plugin,
index,
installedAtTimestamp: getInstalledAtTimestamp(plugin),
}))
.sort((left, right) => {
const fallbackNameCompare = compareInstalledPluginNames(
left.plugin,
right.plugin,
);
const fallbackResult =
fallbackNameCompare !== 0 ? fallbackNameCompare : left.index - right.index;
if (installedSortBy.value === "install_time") {
const leftTimestamp = left.installedAtTimestamp;
const rightTimestamp = right.installedAtTimestamp;
if (leftTimestamp == null && rightTimestamp == null) {
return fallbackResult;
}
if (leftTimestamp == null) {
return 1;
}
if (rightTimestamp == null) {
return -1;
}
const timeDiff =
installedSortOrder.value === "desc"
? rightTimestamp - leftTimestamp
: leftTimestamp - rightTimestamp;
return timeDiff !== 0 ? timeDiff : fallbackResult;
}
if (installedSortBy.value === "name") {
const nameCompare = compareInstalledPluginNames(left.plugin, right.plugin);
if (nameCompare !== 0) {
return installedSortOrder.value === "desc"
? -nameCompare
: nameCompare;
}
return left.index - right.index;
}
if (installedSortBy.value === "author") {
const authorCompare = compareInstalledPluginAuthors(
left.plugin,
right.plugin,
);
if (authorCompare !== 0) {
return installedSortOrder.value === "desc"
? -authorCompare
: authorCompare;
}
return fallbackResult;
}
if (installedSortBy.value === "update_status") {
const leftHasUpdate = left.plugin?.has_update ? 1 : 0;
const rightHasUpdate = right.plugin?.has_update ? 1 : 0;
const updateDiff =
installedSortOrder.value === "desc"
? rightHasUpdate - leftHasUpdate
: leftHasUpdate - rightHasUpdate;
return updateDiff !== 0 ? updateDiff : fallbackResult;
}
return fallbackResult;
})
.map((item) => item.plugin);
};
// 通过搜索过滤插件
const filteredPlugins = computed(() => {
const plugins = filteredExtensions.value.filter((plugin) => {
if (installedStatusFilter.value === "enabled") {
return !!plugin.activated;
}
if (installedStatusFilter.value === "disabled") {
return !plugin.activated;
}
return true;
});
const query = buildSearchQuery(pluginSearch.value);
const filtered = query
? plugins.filter((plugin) => matchesPluginSearch(plugin, query))
: plugins;
return sortInstalledPlugins(filtered);
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
const query = buildSearchQuery(debouncedMarketSearch.value);
if (!query) {
return pluginMarketData.value;
}
return pluginMarketData.value.filter((plugin) =>
matchesPluginSearch(plugin, query),
);
});
// 所有插件列表,推荐插件排在前面
const sortedPlugins = computed(() => {
let plugins = [...filteredMarketPlugins.value];
// 根据排序选项排序
if (sortBy.value === "stars") {
// 按 star 数排序
plugins.sort((a, b) => {
const starsA = a.stars ?? 0;
const starsB = b.stars ?? 0;
return sortOrder.value === "desc" ? starsB - starsA : starsA - starsB;
});
} else if (sortBy.value === "author") {
// 按作者名字典序排序
plugins.sort((a, b) => {
const authorA = (a.author ?? "").toLowerCase();
const authorB = (b.author ?? "").toLowerCase();
const result = authorA.localeCompare(authorB);
return sortOrder.value === "desc" ? -result : result;
});
} else if (sortBy.value === "updated") {
// 按更新时间排序
plugins.sort((a, b) => {
const dateA = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const dateB = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return sortOrder.value === "desc" ? dateB - dateA : dateA - dateB;
});
} else {
// default: 推荐插件排在前面
const pinned = plugins.filter((plugin) => plugin?.pinned);
const notPinned = plugins.filter((plugin) => !plugin?.pinned);
return [...pinned, ...notPinned];
}
return plugins;
});
const RANDOM_PLUGINS_COUNT = 3;
const randomPlugins = computed(() => {
const allPlugins = pluginMarketData.value;
if (allPlugins.length === 0) return [];
const pluginsByName = new Map(allPlugins.map((plugin) => [plugin.name, plugin]));
const selected = randomPluginNames.value
.map((name) => pluginsByName.get(name))
.filter(Boolean);
if (selected.length > 0) {
return selected;
}
return allPlugins.slice(0, Math.min(RANDOM_PLUGINS_COUNT, allPlugins.length));
});
const shufflePlugins = (plugins) => {
const shuffled = [...plugins];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const refreshRandomPlugins = () => {
const shuffled = shufflePlugins(pluginMarketData.value);
randomPluginNames.value = shuffled
.slice(0, Math.min(RANDOM_PLUGINS_COUNT, shuffled.length))
.map((plugin) => plugin.name);
};
// 分页计算属性
const displayItemsPerPage = 9; // 固定每页显示9个卡片3行
const totalPages = computed(() => {
return Math.ceil(sortedPlugins.value.length / displayItemsPerPage);
});
const paginatedPlugins = computed(() => {
const start = (currentPage.value - 1) * displayItemsPerPage;
const end = start + displayItemsPerPage;
return sortedPlugins.value.slice(start, end);
});
const updatableExtensions = computed(() => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
return data.filter((ext) => ext.has_update);
});
// 方法
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
// 保存到 localStorage
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("showReservedPlugins", showReserved.value.toString());
}
};
const toast = (message, success) => {
snack_message.value = message;
snack_show.value = true;
snack_success.value = success;
};
const resetLoadingDialog = () => {
loadingDialog.show = false;
loadingDialog.title = tm("dialogs.loading.title");
loadingDialog.statusCode = 0;
loadingDialog.result = "";
};
const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
loadingDialog.statusCode = statusCode;
loadingDialog.result = result;
if (timeToClose === -1) return;
setTimeout(resetLoadingDialog, timeToClose);
};
const failedPluginsDict = ref({});
const failedPluginItems = computed(() =>
buildFailedPluginItems(failedPluginsDict.value),
);
const getExtensions = async () => {
loading_.value = true;
try {
const res = await axios.get("/api/plugin/get");
Object.assign(extension_data, res.data);
const failRes = await axios.get("/api/plugin/source/get-failed-plugins");
failedPluginsDict.value = failRes.data.data || {};
checkUpdate();
} catch (err) {
toast(err, "error");
} finally {
loading_.value = false;
}
};
const handleReloadAllFailed = async () => {
const dirNames = Object.keys(failedPluginsDict.value);
if (dirNames.length === 0) {
toast("没有需要重载的失败插件", "info");
return;
}
loading_.value = true;
try {
const promises = dirNames.map(dir =>
axios.post("/api/plugin/reload-failed", { dir_name: dir })
);
await Promise.all(promises);
toast("已尝试重载所有失败插件", "success");
// 清空 message 关闭对话框
extension_data.message = "";
// 刷新列表
await getExtensions();
} catch (e) {
console.error("重载失败:", e);
toast("批量重载过程中出现错误", "error");
} finally {
loading_.value = false;
}
};
const reloadFailedPlugin = async (dirName) => {
if (!dirName) return;
try {
const res = await axios.post("/api/plugin/reload-failed", { dir_name: dirName });
if (res.data.status === "error") {
toast(res.data.message || tm("messages.reloadFailed"), "error");
return;
}
toast(res.data.message || tm("messages.reloadSuccess"), "success");
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.reloadFailed")), "error");
}
};
const requestUninstall = (target) => {
if (!target?.id || !target?.kind) return;
uninstallTarget.value = target;
showUninstallDialog.value = true;
};
const uninstall = async (
target,
{ deleteConfig = false, deleteData = false, skipConfirm = false } = {},
) => {
if (!target?.id || !target?.kind) return;
if (!skipConfirm) {
requestUninstall(target);
return;
}
const isFailed = target.kind === "failed";
const endpoint = isFailed
? "/api/plugin/uninstall-failed"
: "/api/plugin/uninstall";
const payload = isFailed
? { dir_name: target.id, delete_config: deleteConfig, delete_data: deleteData }
: { name: target.id, delete_config: deleteConfig, delete_data: deleteData };
toast(`${tm("messages.uninstalling")} ${target.id}`, "primary");
try {
const res = await axios.post(endpoint, payload);
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
if (!isFailed) {
Object.assign(extension_data, res.data);
}
toast(res.data.message, "success");
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.operationFailed")), "error");
}
};
const requestUninstallPlugin = (name) => {
if (!name) return;
uninstall({ kind: "normal", id: name }, { skipConfirm: false });
};
const requestUninstallFailedPlugin = (dirName) => {
if (!dirName) return;
uninstall({ kind: "failed", id: dirName }, { skipConfirm: false });
};
const checkUpdate = () => {
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
pluginMarketData.value.forEach((plugin) => {
if (plugin.repo) {
onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
}
onlinePluginsNameMap.set(plugin.name, plugin);
});
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
data.forEach((extension) => {
const repoKey = extension.repo?.toLowerCase();
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
const matchedPlugin = onlinePlugin || onlinePluginByName;
if (matchedPlugin) {
extension.online_version = matchedPlugin.version;
extension.has_update =
extension.version !== matchedPlugin.version &&
matchedPlugin.version !== tm("status.unknown");
} else {
extension.has_update = false;
}
});
};
const uninstallExtension = async (
extensionName,
optionsOrSkipConfirm = false,
) => {
if (!extensionName) return;
if (typeof optionsOrSkipConfirm === "boolean") {
return uninstall(
{ kind: "normal", id: extensionName },
{ skipConfirm: optionsOrSkipConfirm },
);
}
return uninstall(
{ kind: "normal", id: extensionName },
{ ...(optionsOrSkipConfirm || {}), skipConfirm: true },
);
};
// 处理卸载确认对话框的确认事件
const handleUninstallConfirm = async (options) => {
const target = uninstallTarget.value;
if (!target) return;
try {
await uninstall(target, { ...(options || {}), skipConfirm: true });
} finally {
uninstallTarget.value = null;
showUninstallDialog.value = false;
}
};
const updateExtension = async (extension_name, forceUpdate = false) => {
// 查找插件信息
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const ext = data.find((e) => e.name === extension_name);
// 如果没有检测到更新且不是强制更新,则弹窗确认
if (!ext?.has_update && !forceUpdate) {
forceUpdateDialog.extensionName = extension_name;
forceUpdateDialog.show = true;
return;
}
loadingDialog.title = tm("status.loading");
loadingDialog.show = true;
try {
const res = await axios.post("/api/plugin/update", {
name: extension_name,
proxy: getSelectedGitHubProxy(),
});
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
}
Object.assign(extension_data, res.data);
onLoadingDialogResult(1, res.data.message);
setTimeout(async () => {
toast(tm("messages.refreshing"), "info", 2000);
try {
await getExtensions();
toast(tm("messages.refreshSuccess"), "success");
// 更新完成后弹出更新日志
viewChangelog({
name: extension_name,
repo: ext?.repo || null,
});
} catch (error) {
const errorMsg =
error.response?.data?.message || error.message || String(error);
toast(`${tm("messages.refreshFailed")}: ${errorMsg}`, "error");
}
}, 1000);
} catch (err) {
toast(err, "error");
}
};
// 确认强制更新
// 显示更新全部插件确认对话框
const showUpdateAllConfirm = () => {
if (updatableExtensions.value.length === 0) return;
updateAllConfirmDialog.show = true;
};
// 确认更新全部插件
const confirmUpdateAll = () => {
updateAllConfirmDialog.show = false;
updateAllExtensions();
};
// 取消更新全部插件
const cancelUpdateAll = () => {
updateAllConfirmDialog.show = false;
};
const confirmForceUpdate = () => {
const name = forceUpdateDialog.extensionName;
forceUpdateDialog.show = false;
forceUpdateDialog.extensionName = "";
updateExtension(name, true);
};
const updateAllExtensions = async () => {
if (updatingAll.value || updatableExtensions.value.length === 0) return;
updatingAll.value = true;
loadingDialog.title = tm("status.loading");
loadingDialog.statusCode = 0;
loadingDialog.result = "";
loadingDialog.show = true;
const targets = updatableExtensions.value.map((ext) => ext.name);
try {
const res = await axios.post("/api/plugin/update-all", {
names: targets,
proxy: getSelectedGitHubProxy(),
});
if (res.data.status === "error") {
onLoadingDialogResult(
2,
res.data.message ||
tm("messages.updateAllFailed", {
failed: targets.length,
total: targets.length,
}),
-1,
);
return;
}
const results = res.data.data?.results || [];
const failures = results.filter((r) => r.status !== "ok");
try {
await getExtensions();
} catch (err) {
const errorMsg =
err.response?.data?.message || err.message || String(err);
failures.push({ name: "refresh", status: "error", message: errorMsg });
}
if (failures.length === 0) {
onLoadingDialogResult(1, tm("messages.updateAllSuccess"));
} else {
const failureText = tm("messages.updateAllFailed", {
failed: failures.length,
total: targets.length,
});
const detail = failures.map((f) => `${f.name}: ${f.message}`).join("\n");
onLoadingDialogResult(2, `${failureText}\n${detail}`, -1);
}
} catch (err) {
const errorMsg = err.response?.data?.message || err.message || String(err);
onLoadingDialogResult(2, errorMsg, -1);
} finally {
updatingAll.value = false;
}
};
const pluginOn = async (extension) => {
try {
const res = await axios.post("/api/plugin/on", { name: extension.name });
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
await getExtensions();
await checkAndPromptConflicts();
} catch (err) {
toast(err, "error");
}
};
const pluginOff = async (extension) => {
try {
const res = await axios.post("/api/plugin/off", { name: extension.name });
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const openExtensionConfig = async (extension_name) => {
curr_namespace.value = extension_name;
configDialog.value = true;
try {
const res = await axios.get(
"/api/config/get?plugin_name=" + extension_name,
);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
} catch (err) {
toast(err, "error");
}
};
const updateConfig = async () => {
try {
const res = await axios.post(
"/api/config/plugin/update?plugin_name=" + curr_namespace.value,
extension_config.config,
);
if (res.data.status === "ok") {
toast(res.data.message, "success");
} else {
toast(res.data.message, "error");
}
configDialog.value = false;
extension_config.metadata = {};
extension_config.config = {};
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const showPluginInfo = (plugin) => {
selectedPlugin.value = plugin;
showPluginInfoDialog.value = true;
};
const reloadPlugin = async (plugin_name) => {
try {
const res = await axios.post("/api/plugin/reload", { name: plugin_name });
if (res.data.status === "error") {
toast(res.data.message || tm("messages.reloadFailed"), "error");
return;
}
toast(tm("messages.reloadSuccess"), "success");
await getExtensions();
} catch (err) {
toast(resolveErrorMessage(err, tm("messages.reloadFailed")), "error");
}
};
const viewReadme = (plugin) => {
readmeDialog.pluginName = plugin.name;
readmeDialog.repoUrl = plugin.repo;
readmeDialog.show = true;
};
// 查看更新日志
const viewChangelog = (plugin) => {
changelogDialog.pluginName = plugin.name;
changelogDialog.repoUrl = plugin.repo;
changelogDialog.show = true;
};
// 为表格视图创建一个处理安装插件的函数
const handleInstallPlugin = async (plugin) => {
if (plugin.tags && plugin.tags.includes("danger")) {
selectedDangerPlugin.value = plugin;
dangerConfirmDialog.value = true;
} else {
selectedMarketInstallPlugin.value = plugin;
extension_url.value = plugin.repo;
dialog.value = true;
uploadTab.value = "url";
}
};
// 确认安装危险插件
const confirmDangerInstall = () => {
if (selectedDangerPlugin.value) {
selectedMarketInstallPlugin.value = selectedDangerPlugin.value;
extension_url.value = selectedDangerPlugin.value.repo;
dialog.value = true;
uploadTab.value = "url";
}
dangerConfirmDialog.value = false;
selectedDangerPlugin.value = null;
};
// 取消安装危险插件
const cancelDangerInstall = () => {
dangerConfirmDialog.value = false;
selectedDangerPlugin.value = null;
};
// 自定义插件源管理方法
const loadCustomSources = async () => {
try {
const res = await axios.get("/api/plugin/source/get");
if (res.data.status === "ok") {
customSources.value = res.data.data;
} else {
toast(res.data.message, "error");
}
} catch (e) {
console.warn("Failed to load custom sources:", e);
customSources.value = [];
}
// 加载当前选中的插件源
const currentSource = localStorage.getItem("selectedPluginSource");
if (currentSource) {
selectedSource.value = currentSource;
}
};
const saveCustomSources = async () => {
try {
const res = await axios.post("/api/plugin/source/save", {
sources: customSources.value,
});
if (res.data.status !== "ok") {
toast(res.data.message, "error");
}
} catch (e) {
toast(e, "error");
}
};
const addCustomSource = () => {
showSourceManagerDialog.value = false;
editingSource.value = false;
originalSourceUrl.value = "";
sourceName.value = "";
sourceUrl.value = "";
showSourceDialog.value = true;
};
const openSourceManagerDialog = async () => {
await loadCustomSources();
showSourceManagerDialog.value = true;
};
const selectPluginSource = (sourceUrl) => {
selectedSource.value = sourceUrl;
if (sourceUrl) {
localStorage.setItem("selectedPluginSource", sourceUrl);
} else {
localStorage.removeItem("selectedPluginSource");
}
// 重新加载插件市场数据
refreshPluginMarket();
};
const sourceSelectItems = computed(() => [
{ title: tm("market.defaultSource"), value: "__default__" },
...customSources.value.map((source) => ({
title: source.name,
value: source.url,
})),
]);
const editCustomSource = (source) => {
if (!source) return;
showSourceManagerDialog.value = false;
editingSource.value = true;
originalSourceUrl.value = source.url;
sourceName.value = source.name;
sourceUrl.value = source.url;
showSourceDialog.value = true;
};
const removeCustomSource = (source) => {
if (!source) return;
showSourceManagerDialog.value = false;
sourceToRemove.value = source;
showRemoveSourceDialog.value = true;
};
const confirmRemoveSource = () => {
if (sourceToRemove.value) {
customSources.value = customSources.value.filter(
(s) => s.url !== sourceToRemove.value.url,
);
saveCustomSources();
// 如果删除的是当前选中的源,切换到默认源
if (selectedSource.value === sourceToRemove.value.url) {
selectedSource.value = null;
localStorage.removeItem("selectedPluginSource");
// 重新加载插件市场数据
refreshPluginMarket();
}
toast(tm("market.sourceRemoved"), "success");
showRemoveSourceDialog.value = false;
sourceToRemove.value = null;
}
};
const saveCustomSource = () => {
const normalizedUrl = sourceUrl.value.trim();
if (!sourceName.value.trim() || !normalizedUrl) {
toast(tm("messages.fillSourceNameAndUrl"), "error");
return;
}
// 检查URL格式
try {
new URL(normalizedUrl);
} catch (e) {
toast(tm("messages.invalidUrl"), "error");
return;
}
if (editingSource.value) {
// 编辑模式:更新现有源
const index = customSources.value.findIndex(
(s) => s.url === originalSourceUrl.value,
);
if (index !== -1) {
customSources.value[index] = {
name: sourceName.value.trim(),
url: normalizedUrl,
};
// 如果编辑的是当前选中的源,更新选中源
if (selectedSource.value === originalSourceUrl.value) {
selectedSource.value = normalizedUrl;
localStorage.setItem("selectedPluginSource", selectedSource.value);
// 重新加载插件市场数据
refreshPluginMarket();
}
}
} else {
// 添加模式:检查是否已存在
if (customSources.value.some((source) => source.url === normalizedUrl)) {
toast(tm("market.sourceExists"), "error");
return;
}
customSources.value.push({
name: sourceName.value.trim(),
url: normalizedUrl,
});
}
saveCustomSources();
toast(
editingSource.value ? tm("market.sourceUpdated") : tm("market.sourceAdded"),
"success",
);
// 重置表单
sourceName.value = "";
sourceUrl.value = "";
editingSource.value = false;
originalSourceUrl.value = "";
showSourceDialog.value = false;
};
// 插件市场显示完整插件名称
const trimExtensionName = () => {
pluginMarketData.value.forEach((plugin) => {
if (plugin.name) {
let name = plugin.name.trim().toLowerCase();
if (name.startsWith("astrbot_plugin_")) {
plugin.trimmedName = name.substring(15);
} else if (name.startsWith("astrbot_") || name.startsWith("astrbot-")) {
plugin.trimmedName = name.substring(8);
} else plugin.trimmedName = plugin.name;
}
});
};
const checkAlreadyInstalled = () => {
const data = Array.isArray(extension_data?.data) ? extension_data.data : [];
const installedRepos = new Set(data.map((ext) => ext.repo?.toLowerCase()));
const installedNames = new Set(data.map((ext) => ext.name));
const installedByRepo = new Map(
data
.filter((ext) => ext.repo)
.map((ext) => [ext.repo.toLowerCase(), ext]),
);
const installedByName = new Map(data.map((ext) => [ext.name, ext]));
for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i];
const matchedInstalled =
(plugin.repo && installedByRepo.get(plugin.repo.toLowerCase())) ||
installedByName.get(plugin.name);
// 兜底:市场源未提供字段时,回填本地已安装插件中的元数据,便于在市场页直接展示
if (matchedInstalled) {
if (
(!Array.isArray(plugin.support_platforms) ||
plugin.support_platforms.length === 0) &&
Array.isArray(matchedInstalled.support_platforms)
) {
plugin.support_platforms = matchedInstalled.support_platforms;
}
if (!plugin.astrbot_version && matchedInstalled.astrbot_version) {
plugin.astrbot_version = matchedInstalled.astrbot_version;
}
}
plugin.installed =
installedRepos.has(plugin.repo?.toLowerCase()) ||
installedNames.has(plugin.name);
}
let installed = [];
let notInstalled = [];
for (let i = 0; i < pluginMarketData.value.length; i++) {
if (pluginMarketData.value[i].installed) {
installed.push(pluginMarketData.value[i]);
} else {
notInstalled.push(pluginMarketData.value[i]);
}
}
pluginMarketData.value = notInstalled.concat(installed);
};
const showVersionCompatibilityWarning = (message) => {
versionCompatibilityDialog.message = message;
versionCompatibilityDialog.show = true;
};
const refreshExtensionsAfterInstallFailure = async () => {
try {
await getExtensions();
} catch (error) {
console.debug("Failed to refresh extensions after install failure:", error);
}
};
const continueInstallIgnoringVersionWarning = async () => {
versionCompatibilityDialog.show = false;
await newExtension(true);
};
const cancelInstallOnVersionWarning = () => {
versionCompatibilityDialog.show = false;
};
const handleInstallResponse = async (resData, { toastStatus = false } = {}) => {
if (
resData.status === "warning" &&
resData.data?.warning_type === "astrbot_version_incompatible"
) {
onLoadingDialogResult(2, resData.message, -1);
showVersionCompatibilityWarning(resData.message);
await refreshExtensionsAfterInstallFailure();
return false;
}
if (toastStatus) {
toast(resData.message, resData.status === "ok" ? "success" : "error");
}
if (resData.status === "error") {
onLoadingDialogResult(2, resData.message, -1);
await refreshExtensionsAfterInstallFailure();
return false;
}
return true;
};
const performInstallRequest = async ({ source, ignoreVersionCheck }) => {
if (source === "file") {
const formData = new FormData();
formData.append("file", upload_file.value);
formData.append("ignore_version_check", String(ignoreVersionCheck));
return axios.post("/api/plugin/install-upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
}
return axios.post("/api/plugin/install", {
url: extension_url.value,
proxy: getSelectedGitHubProxy(),
ignore_version_check: ignoreVersionCheck,
});
};
const finalizeSuccessfulInstall = async (resData, source) => {
if (source === "file") {
upload_file.value = null;
} else {
extension_url.value = "";
}
onLoadingDialogResult(1, resData.message);
dialog.value = false;
await getExtensions();
viewReadme({
name: resData.data.name,
repo: resData.data.repo || null,
});
await checkAndPromptConflicts();
};
const newExtension = async (ignoreVersionCheck = false) => {
if (extension_url.value === "" && upload_file.value === null) {
toast(tm("messages.fillUrlOrFile"), "error");
return;
}
if (extension_url.value !== "" && upload_file.value !== null) {
toast(tm("messages.dontFillBoth"), "error");
return;
}
loading_.value = true;
loadingDialog.title = tm("status.loading");
loadingDialog.show = true;
const source = upload_file.value !== null ? "file" : "url";
toast(
source === "file"
? tm("messages.installing")
: tm("messages.installingFromUrl") + " " + extension_url.value,
"primary",
);
try {
const res = await performInstallRequest({ source, ignoreVersionCheck });
loading_.value = false;
const canContinue = await handleInstallResponse(res.data, {
toastStatus: source === "url",
});
if (!canContinue) return;
await finalizeSuccessfulInstall(res.data, source);
} catch (err) {
loading_.value = false;
const message = resolveErrorMessage(err, tm("messages.installFailed"));
if (source === "url") {
toast(message, "error");
}
onLoadingDialogResult(2, message, -1);
await refreshExtensionsAfterInstallFailure();
}
};
const normalizePlatformList = (platforms) => {
if (!Array.isArray(platforms)) return [];
return platforms.filter((item) => typeof item === "string");
};
const getPlatformDisplayList = (platforms) => {
return normalizePlatformList(platforms).map((platformId) =>
getPlatformDisplayName(platformId),
);
};
const resolveSelectedInstallPlugin = () => {
if (
selectedMarketInstallPlugin.value &&
selectedMarketInstallPlugin.value.repo === extension_url.value
) {
return selectedMarketInstallPlugin.value;
}
return pluginMarketData.value.find((plugin) => plugin.repo === extension_url.value) || null;
};
const selectedInstallPlugin = computed(() => resolveSelectedInstallPlugin());
const checkInstallCompatibility = async () => {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
const plugin = selectedInstallPlugin.value;
if (!plugin?.astrbot_version || uploadTab.value !== "url") {
return;
}
try {
const res = await axios.post("/api/plugin/check-compat", {
astrbot_version: plugin.astrbot_version,
});
if (res.data.status === "ok") {
installCompat.checked = true;
installCompat.compatible = !!res.data.data?.compatible;
installCompat.message = res.data.data?.message || "";
}
} catch (err) {
console.debug("Failed to check plugin compatibility:", err);
}
};
// 刷新插件市场数据
const refreshPluginMarket = async () => {
refreshingMarket.value = true;
try {
// 强制刷新插件市场数据
const data = await commonStore.getPluginCollections(
true,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
currentPage.value = 1; // 重置到第一页
toast(tm("messages.refreshSuccess"), "success");
} catch (err) {
toast(tm("messages.refreshFailed") + " " + err, "error");
} finally {
refreshingMarket.value = false;
}
};
// 生命周期
onMounted(async () => {
if (!syncTabFromHash(getLocationHash())) {
if (typeof window !== "undefined") {
window.location.hash = `#${activeTab.value}`;
}
}
await getExtensions();
// 加载自定义插件源
loadCustomSources();
// 检查是否有 open_config 参数
let urlParams;
if (window.location.hash) {
// For hash mode (#/path?param=value)
const hashQuery = window.location.hash.split("?")[1] || "";
urlParams = new URLSearchParams(hashQuery);
} else {
// For history mode (/path?param=value)
urlParams = new URLSearchParams(window.location.search);
}
console.log("URL Parameters:", urlParams.toString());
const plugin_name = urlParams.get("open_config");
if (plugin_name) {
console.log(`Opening config for plugin: ${plugin_name}`);
openExtensionConfig(plugin_name);
}
try {
const data = await commonStore.getPluginCollections(
false,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
refreshRandomPlugins();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
}
});
// 处理语言切换事件,重新加载插件配置以获取插件的 i18n 数据
const handleLocaleChange = () => {
// 如果配置对话框是打开的,重新加载当前插件的配置
if (configDialog.value && currentConfigPlugin.value) {
openExtensionConfig(currentConfigPlugin.value);
}
};
// 监听语言切换事件
window.addEventListener("astrbot-locale-changed", handleLocaleChange);
// 清理事件监听器
onUnmounted(() => {
window.removeEventListener("astrbot-locale-changed", handleLocaleChange);
});
// 搜索防抖处理
let searchDebounceTimer = null;
watch(marketSearch, (newVal) => {
if (searchDebounceTimer) {
clearTimeout(searchDebounceTimer);
}
searchDebounceTimer = setTimeout(() => {
debouncedMarketSearch.value = newVal;
// 搜索时重置到第一页
currentPage.value = 1;
}, 300); // 300ms 防抖延迟
});
// 监听显示模式变化并保存到 localStorage
watch(isListView, (newVal) => {
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("pluginListViewMode", String(newVal));
}
});
watch(
[() => dialog.value, () => extension_url.value, () => uploadTab.value],
async ([dialogOpen, _, currentUploadTab]) => {
if (!dialogOpen || currentUploadTab !== "url") {
installCompat.checked = false;
installCompat.compatible = true;
installCompat.message = "";
return;
}
await checkInstallCompatibility();
},
);
watch(
() => route.fullPath,
() => {
const tab = extractTabFromHash(getLocationHash());
if (isValidTab(tab) && tab !== activeTab.value) {
activeTab.value = tab;
}
},
);
watch(activeTab, (newTab) => {
if (!isValidTab(newTab)) return;
const currentTab = extractTabFromHash(getLocationHash());
if (currentTab === newTab) return;
const hash = getLocationHash();
const lastHashIndex = hash.lastIndexOf("#");
const nextHash =
lastHashIndex > 0 ? `${hash.slice(0, lastHashIndex)}#${newTab}` : `#${newTab}`;
if (typeof window !== "undefined") {
window.location.hash = nextHash;
}
});
return {
commonStore,
t,
tm,
router,
route,
getSelectedGitHubProxy,
conflictDialog,
checkAndPromptConflicts,
handleConflictConfirm,
fileInput,
activeTab,
validTabs,
isValidTab,
getLocationHash,
extractTabFromHash,
syncTabFromHash,
extension_data,
getInitialShowReserved,
showReserved,
snack_message,
snack_show,
snack_success,
configDialog,
extension_config,
pluginMarketData,
loadingDialog,
showPluginInfoDialog,
selectedPlugin,
curr_namespace,
updatingAll,
readmeDialog,
forceUpdateDialog,
updateAllConfirmDialog,
changelogDialog,
getInitialListViewMode,
isListView,
pluginSearch,
installedStatusFilter,
installedSortBy,
installedSortOrder,
loading_,
currentPage,
dangerConfirmDialog,
selectedDangerPlugin,
selectedMarketInstallPlugin,
installCompat,
versionCompatibilityDialog,
showUninstallDialog,
uninstallTarget,
showSourceDialog,
showSourceManagerDialog,
sourceName,
sourceUrl,
customSources,
selectedSource,
showRemoveSourceDialog,
sourceToRemove,
editingSource,
originalSourceUrl,
extension_url,
dialog,
upload_file,
uploadTab,
showPluginFullName,
marketSearch,
debouncedMarketSearch,
refreshingMarket,
sortBy,
sortOrder,
randomPluginNames,
showRandomPlugins,
normalizeStr,
toPinyinText,
toInitials,
plugin_handler_info_headers,
installedSortItems,
installedSortUsesOrder,
pluginHeaders,
filteredExtensions,
filteredPlugins,
filteredMarketPlugins,
sortedPlugins,
RANDOM_PLUGINS_COUNT,
randomPlugins,
shufflePlugins,
refreshRandomPlugins,
toggleRandomPluginsVisibility,
collapseRandomPlugins,
displayItemsPerPage,
totalPages,
paginatedPlugins,
updatableExtensions,
toggleShowReserved,
toast,
resetLoadingDialog,
onLoadingDialogResult,
failedPluginsDict,
failedPluginItems,
getExtensions,
handleReloadAllFailed,
reloadFailedPlugin,
checkUpdate,
uninstallExtension,
requestUninstallPlugin,
requestUninstallFailedPlugin,
handleUninstallConfirm,
updateExtension,
showUpdateAllConfirm,
confirmUpdateAll,
cancelUpdateAll,
confirmForceUpdate,
updateAllExtensions,
pluginOn,
pluginOff,
openExtensionConfig,
updateConfig,
showPluginInfo,
reloadPlugin,
viewReadme,
viewChangelog,
handleInstallPlugin,
confirmDangerInstall,
cancelDangerInstall,
loadCustomSources,
saveCustomSources,
addCustomSource,
openSourceManagerDialog,
selectPluginSource,
sourceSelectItems,
editCustomSource,
removeCustomSource,
confirmRemoveSource,
saveCustomSource,
trimExtensionName,
checkAlreadyInstalled,
showVersionCompatibilityWarning,
continueInstallIgnoringVersionWarning,
cancelInstallOnVersionWarning,
newExtension,
normalizePlatformList,
getPlatformDisplayList,
resolveSelectedInstallPlugin,
selectedInstallPlugin,
checkInstallCompatibility,
refreshPluginMarket,
handleLocaleChange,
searchDebounceTimer,
};
};