mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
* 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
1702 lines
49 KiB
JavaScript
1702 lines
49 KiB
JavaScript
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,
|
||
};
|
||
};
|