Files
AstrBot/dashboard/src/views/ExtensionPage.vue
Soulter ae6e0db053 perf: webui
Co-authored-by: IGCrystal <IGCrystal@wenturc.com>
2026-02-03 14:40:45 +08:00

2713 lines
89 KiB
Vue
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.
<script setup>
import ExtensionCard from "@/components/shared/ExtensionCard.vue";
import AstrBotConfig from "@/components/shared/AstrBotConfig.vue";
import ConsoleDisplayer from "@/components/shared/ConsoleDisplayer.vue";
import ReadmeDialog from "@/components/shared/ReadmeDialog.vue";
import ProxySelector from "@/components/shared/ProxySelector.vue";
import UninstallConfirmDialog from "@/components/shared/UninstallConfirmDialog.vue";
import McpServersSection from "@/components/extension/McpServersSection.vue";
import SkillsSection from "@/components/extension/SkillsSection.vue";
import ComponentPanel from "@/components/extension/componentPanel/index.vue";
import axios from "axios";
import { pinyin } from "pinyin-pro";
import { useCommonStore } from "@/stores/common";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { ref, computed, onMounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const commonStore = useCommonStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/extension");
const router = useRouter();
const route = useRoute();
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 loading_ = ref(false);
// 分页相关
const currentPage = ref(1);
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
// 卸载插件确认对话框(列表模式用)
const showUninstallDialog = ref(false);
const pluginToUninstall = ref(null);
// 自定义插件源相关
const showSourceDialog = 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 normalizeStr = (s) => (s ?? "").toString().toLowerCase().trim();
const toPinyinText = (s) =>
pinyin(s ?? "", { toneType: "none" })
.toLowerCase()
.replace(/\s+/g, "");
const toInitials = (s) =>
pinyin(s ?? "", { pattern: "first", toneType: "none" })
.toLowerCase()
.replace(/\s+/g, "");
const marketCustomFilter = (value, query, item) => {
const q = normalizeStr(query);
if (!q) return true;
const candidates = new Set();
if (value != null) candidates.add(String(value));
if (item?.name) candidates.add(String(item.name));
if (item?.trimmedName) candidates.add(String(item.trimmedName));
if (item?.display_name) candidates.add(String(item.display_name));
if (item?.desc) candidates.add(String(item.desc));
if (item?.author) candidates.add(String(item.author));
for (const v of candidates) {
const nv = normalizeStr(v);
if (nv.includes(q)) return true;
const pv = toPinyinText(v);
if (pv.includes(q)) return true;
const iv = toInitials(v);
if (iv.includes(q)) return true;
}
return false;
};
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 pluginHeaders = computed(() => [
{ title: tm("table.headers.name"), key: "name", width: "200px" },
{ title: tm("table.headers.description"), key: "desc", maxWidth: "250px" },
{ title: tm("table.headers.version"), key: "version", width: "100px" },
{ title: tm("table.headers.author"), key: "author", width: "100px" },
{ title: tm("table.headers.status"), key: "activated", width: "100px" },
{
title: tm("table.headers.actions"),
key: "actions",
sortable: false,
width: "220px",
},
]);
// 过滤要显示的插件
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 filteredPlugins = computed(() => {
if (!pluginSearch.value) {
return filteredExtensions.value;
}
const search = pluginSearch.value.toLowerCase();
return filteredExtensions.value.filter((plugin) => {
return (
plugin.name?.toLowerCase().includes(search) ||
plugin.desc?.toLowerCase().includes(search) ||
plugin.author?.toLowerCase().includes(search)
);
});
});
// 过滤后的插件市场数据(带搜索)
const filteredMarketPlugins = computed(() => {
if (!debouncedMarketSearch.value) {
return pluginMarketData.value;
}
const search = debouncedMarketSearch.value.toLowerCase();
return pluginMarketData.value.filter((plugin) => {
// 使用自定义过滤器
return (
marketCustomFilter(plugin.name, search, plugin) ||
marketCustomFilter(plugin.desc, search, plugin) ||
marketCustomFilter(plugin.author, search, plugin)
);
});
});
// 所有插件列表,推荐插件排在前面
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 displayItemsPerPage = 9; // 固定每页显示6个卡片2行
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 getExtensions = async () => {
loading_.value = true;
try {
const res = await axios.get("/api/plugin/get");
Object.assign(extension_data, res.data);
checkUpdate();
} catch (err) {
toast(err, "error");
} finally {
loading_.value = 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 (
extension_name,
optionsOrSkipConfirm = false,
) => {
let deleteConfig = false;
let deleteData = false;
let skipConfirm = false;
// 处理参数:可能是布尔值(旧的 skipConfirm或对象新的选项
if (typeof optionsOrSkipConfirm === "boolean") {
skipConfirm = optionsOrSkipConfirm;
} else if (
typeof optionsOrSkipConfirm === "object" &&
optionsOrSkipConfirm !== null
) {
deleteConfig = optionsOrSkipConfirm.deleteConfig || false;
deleteData = optionsOrSkipConfirm.deleteData || false;
skipConfirm = true; // 如果传递了选项对象,说明已经确认过了
}
// 如果没有跳过确认且没有传递选项对象,显示自定义卸载对话框
if (!skipConfirm) {
pluginToUninstall.value = extension_name;
showUninstallDialog.value = true;
return; // 等待对话框回调
}
// 执行卸载
toast(tm("messages.uninstalling") + " " + extension_name, "primary");
try {
const res = await axios.post("/api/plugin/uninstall", {
name: extension_name,
delete_config: deleteConfig,
delete_data: deleteData,
});
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
Object.assign(extension_data, res.data);
toast(res.data.message, "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
// 处理卸载确认对话框的确认事件
const handleUninstallConfirm = (options) => {
if (pluginToUninstall.value) {
uninstallExtension(pluginToUninstall.value, options);
pluginToUninstall.value = null;
}
};
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, "error");
return;
}
toast(tm("messages.reloadSuccess"), "success");
getExtensions();
} catch (err) {
toast(err, "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 {
extension_url.value = plugin.repo;
dialog.value = true;
uploadTab.value = "url";
}
};
// 确认安装危险插件
const confirmDangerInstall = () => {
if (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 = () => {
editingSource.value = false;
originalSourceUrl.value = "";
sourceName.value = "";
sourceUrl.value = "";
showSourceDialog.value = true;
};
const selectPluginSource = (sourceUrl) => {
selectedSource.value = sourceUrl;
if (sourceUrl) {
localStorage.setItem("selectedPluginSource", sourceUrl);
} else {
localStorage.removeItem("selectedPluginSource");
}
// 重新加载插件市场数据
refreshPluginMarket();
};
// 获取当前选中的源对象
const selectedSourceObj = computed(() => {
if (!selectedSource.value) return null;
return (
customSources.value.find((s) => s.url === selectedSource.value) || null
);
});
const editCustomSource = (source) => {
if (!source) return;
editingSource.value = true;
originalSourceUrl.value = source.url;
sourceName.value = source.name;
sourceUrl.value = source.url;
showSourceDialog.value = true;
};
const removeCustomSource = (source) => {
if (!source) return;
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));
for (let i = 0; i < pluginMarketData.value.length; i++) {
const plugin = pluginMarketData.value[i];
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 newExtension = async () => {
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;
if (upload_file.value !== null) {
toast(tm("messages.installing"), "primary");
const formData = new FormData();
formData.append("file", upload_file.value);
axios
.post("/api/plugin/install-upload", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
})
.then(async (res) => {
loading_.value = false;
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
}
upload_file.value = null;
onLoadingDialogResult(1, res.data.message);
dialog.value = false;
await getExtensions();
viewReadme({
name: res.data.data.name,
repo: res.data.data.repo || null,
});
await checkAndPromptConflicts();
})
.catch((err) => {
loading_.value = false;
onLoadingDialogResult(2, err, -1);
});
} else {
toast(
tm("messages.installingFromUrl") + " " + extension_url.value,
"primary",
);
axios
.post("/api/plugin/install", {
url: extension_url.value,
proxy: getSelectedGitHubProxy(),
})
.then(async (res) => {
loading_.value = false;
toast(res.data.message, res.data.status === "ok" ? "success" : "error");
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
}
extension_url.value = "";
onLoadingDialogResult(1, res.data.message);
dialog.value = false;
await getExtensions();
viewReadme({
name: res.data.data.name,
repo: res.data.data.repo || null,
});
await checkAndPromptConflicts();
})
.catch((err) => {
loading_.value = false;
toast(tm("messages.installFailed") + " " + err, "error");
onLoadingDialogResult(2, err, -1);
});
}
};
// 刷新插件市场数据
const refreshPluginMarket = async () => {
refreshingMarket.value = true;
try {
// 强制刷新插件市场数据
const data = await commonStore.getPluginCollections(
true,
selectedSource.value,
);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
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();
} catch (err) {
toast(tm("messages.getMarketDataFailed") + " " + err, "error");
}
});
// 搜索防抖处理
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(
() => 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;
}
});
</script>
<template>
<v-row>
<v-col cols="12" md="12">
<v-card variant="flat" style="background-color: transparent">
<!-- 标签页 -->
<v-card-text style="padding: 0px 12px">
<!-- 标签栏和搜索栏 - 响应式布局 -->
<div class="mb-4 d-flex flex-wrap">
<!-- 标签栏 -->
<v-tabs v-model="activeTab" color="primary">
<v-tab value="installed">
<v-icon class="mr-2">mdi-puzzle</v-icon>
{{ tm("tabs.installedPlugins") }}
</v-tab>
<v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon>
{{ tm("tabs.market") }}
</v-tab>
<v-tab value="mcp">
<v-icon class="mr-2">mdi-server-network</v-icon>
{{ tm("tabs.installedMcpServers") }}
</v-tab>
<v-tab value="skills">
<v-icon class="mr-2">mdi-lightning-bolt</v-icon>
{{ tm("tabs.skills") }}
</v-tab>
<v-tab value="components">
<v-icon class="mr-2">mdi-wrench</v-icon>
{{ tm("tabs.handlersOperation") }}
</v-tab>
</v-tabs>
<!-- 搜索栏 - 在移动端时独占一行 -->
<div
style="
flex-grow: 1;
min-width: 250px;
max-width: 400px;
margin-left: auto;
margin-top: 8px;
"
>
<v-text-field
v-if="activeTab === 'market'"
v-model="marketSearch"
density="compact"
:label="tm('search.marketPlaceholder')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
flat
hide-details
single-line
>
</v-text-field>
<v-text-field
v-else-if="activeTab === 'installed'"
v-model="pluginSearch"
density="compact"
:label="tm('search.placeholder')"
prepend-inner-icon="mdi-magnify"
variant="solo-filled"
flat
hide-details
single-line
>
</v-text-field>
</div>
</div>
<!-- 已安装插件标签页内容 -->
<v-tab-item v-show="activeTab === 'installed'">
<v-row class="mb-4">
<v-col cols="12" class="d-flex align-center flex-wrap ga-2">
<v-btn-group
variant="outlined"
density="comfortable"
color="primary"
>
<v-btn
@click="isListView = false"
:color="!isListView ? 'primary' : undefined"
:variant="!isListView ? 'flat' : 'outlined'"
>
<v-icon>mdi-view-grid</v-icon>
</v-btn>
<v-btn
@click="isListView = true"
:color="isListView ? 'primary' : undefined"
:variant="isListView ? 'flat' : 'outlined'"
>
<v-icon>mdi-view-list</v-icon>
</v-btn>
</v-btn-group>
<v-btn class="ml-2" variant="tonal" @click="toggleShowReserved">
<v-icon>{{
showReserved ? "mdi-eye-off" : "mdi-eye"
}}</v-icon>
{{
showReserved
? tm("buttons.hideSystemPlugins")
: tm("buttons.showSystemPlugins")
}}
</v-btn>
<v-btn
class="ml-2"
color="warning"
variant="tonal"
:disabled="updatableExtensions.length === 0"
:loading="updatingAll"
@click="showUpdateAllConfirm"
>
<v-icon>mdi-update</v-icon>
{{ tm("buttons.updateAll") }}
</v-btn>
<v-btn
class="ml-2"
color="primary"
variant="tonal"
@click="dialog = true"
>
<v-icon>mdi-plus</v-icon>
{{ tm("buttons.install") }}
</v-btn>
<v-col cols="12" sm="auto" class="ml-auto">
<v-dialog max-width="500px" v-if="extension_data.message">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon
size="small"
color="error"
class="ml-2"
variant="tonal"
>
<v-icon>mdi-alert-circle</v-icon>
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card class="rounded-lg">
<v-card-title class="headline d-flex align-center">
<v-icon color="error" class="mr-2"
>mdi-alert-circle</v-icon
>
{{ tm("dialogs.error.title") }}
</v-card-title>
<v-card-text>
<p class="text-body-1">
{{ extension_data.message }}
</p>
<p class="text-caption mt-2">
{{ tm("dialogs.error.checkConsole") }}
</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="primary"
@click="isActive.value = false"
>{{ tm("buttons.close") }}</v-btn
>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</v-col>
</v-col>
</v-row>
<v-fade-transition hide-on-leave>
<!-- 表格视图 -->
<div v-if="isListView">
<v-card class="rounded-lg overflow-hidden elevation-1">
<v-data-table
:headers="pluginHeaders"
:items="filteredPlugins"
:loading="loading_"
item-key="name"
hover
>
<template v-slot:loader>
<v-row class="py-8 d-flex align-center justify-center">
<v-progress-circular
indeterminate
color="primary"
></v-progress-circular>
<span class="ml-2">{{ tm("status.loading") }}</span>
</v-row>
</template>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2">
<div
v-if="item.logo"
class="mr-3"
style="flex-shrink: 0"
>
<img
:src="item.logo"
:alt="item.name"
style="
height: 40px;
width: 40px;
border-radius: 8px;
object-fit: cover;
"
/>
</div>
<div v-else class="mr-3" style="flex-shrink: 0">
<img
:src="defaultPluginIcon"
:alt="item.name"
style="
height: 40px;
width: 40px;
border-radius: 8px;
object-fit: cover;
"
/>
</div>
<div>
<div class="text-subtitle-1 font-weight-medium">
{{
item.display_name && item.display_name.length
? item.display_name
: item.name
}}
</div>
<div
v-if="item.display_name && item.display_name.length"
class="text-caption text-medium-emphasis mt-1"
>
{{ item.name }}
</div>
<div
v-if="item.reserved"
class="d-flex align-center mt-1"
>
<v-chip
color="primary"
size="x-small"
class="font-weight-medium"
>{{ tm("status.system") }}</v-chip
>
</div>
</div>
</div>
</template>
<template v-slot:item.desc="{ item }">
<div
class="text-body-2 text-medium-emphasis mt-2 mb-2"
style="
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ item.desc }}
</div>
</template>
<template v-slot:item.version="{ item }">
<div class="d-flex align-center">
<span class="text-body-2">{{ item.version }}</span>
<v-icon
v-if="item.has_update"
color="warning"
size="small"
class="ml-1"
>mdi-alert</v-icon
>
<v-tooltip v-if="item.has_update" activator="parent">
<span
>{{ tm("messages.hasUpdate") }}
{{ item.online_version }}</span
>
</v-tooltip>
</div>
</template>
<template v-slot:item.author="{ item }">
<div class="text-body-2">{{ item.author }}</div>
</template>
<template v-slot:item.activated="{ item }">
<v-chip
:color="item.activated ? 'success' : 'error'"
size="small"
class="font-weight-medium"
:variant="item.activated ? 'flat' : 'outlined'"
>
{{
item.activated
? tm("status.enabled")
: tm("status.disabled")
}}
</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center">
<v-btn-group
density="comfortable"
variant="text"
color="primary"
>
<v-btn
v-if="!item.activated"
icon
size="small"
color="success"
@click="pluginOn(item)"
>
<v-icon>mdi-play</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.enable")
}}</v-tooltip>
</v-btn>
<v-btn
v-else
icon
size="small"
color="error"
@click="pluginOff(item)"
>
<v-icon>mdi-pause</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.disable")
}}</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
@click="reloadPlugin(item.name)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.reload")
}}</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
@click="openExtensionConfig(item.name)"
>
<v-icon>mdi-cog</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.configure")
}}</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
@click="showPluginInfo(item)"
>
<v-icon>mdi-information</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.viewInfo")
}}</v-tooltip>
</v-btn>
<v-btn
v-if="item.repo"
icon
size="small"
@click="viewReadme(item)"
>
<v-icon>mdi-book-open-page-variant</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.viewDocs")
}}</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
@click="updateExtension(item.name)"
>
<v-icon>mdi-update</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.update")
}}</v-tooltip>
</v-btn>
<v-btn
icon
size="small"
color="error"
@click="uninstallExtension(item.name)"
:disabled="item.reserved"
>
<v-icon>mdi-delete</v-icon>
<v-tooltip activator="parent" location="top">{{
tm("tooltips.uninstall")
}}</v-tooltip>
</v-btn>
</v-btn-group>
</div>
</template>
<template v-slot:no-data>
<div class="text-center pa-8">
<v-icon size="64" color="info" class="mb-4"
>mdi-puzzle-outline</v-icon
>
<div class="text-h5 mb-2">
{{ tm("empty.noPlugins") }}
</div>
<div class="text-body-1 mb-4">
{{ tm("empty.noPluginsDesc") }}
</div>
</div>
</template>
</v-data-table>
</v-card>
</div>
<!-- 卡片视图 -->
<div v-else>
<v-row v-if="filteredPlugins.length === 0" class="text-center">
<v-col cols="12" class="pa-2">
<v-icon size="64" color="info" class="mb-4"
>mdi-puzzle-outline</v-icon
>
<div class="text-h5 mb-2">{{ tm("empty.noPlugins") }}</div>
<div class="text-body-1 mb-4">
{{ tm("empty.noPluginsDesc") }}
</div>
</v-col>
</v-row>
<v-row>
<v-col
cols="12"
md="6"
lg="4"
v-for="extension in filteredPlugins"
:key="extension.name"
class="pb-2"
>
<ExtensionCard
:extension="extension"
class="rounded-lg"
style="background-color: rgb(var(--v-theme-mcpCardBg))"
@configure="openExtensionConfig(extension.name)"
@uninstall="
(ext, options) => uninstallExtension(ext.name, options)
"
@update="updateExtension(extension.name)"
@reload="reloadPlugin(extension.name)"
@toggle-activation="
extension.activated
? pluginOff(extension)
: pluginOn(extension)
"
@view-handlers="showPluginInfo(extension)"
@view-readme="viewReadme(extension)"
@view-changelog="viewChangelog(extension)"
>
</ExtensionCard>
</v-col>
</v-row>
</div>
</v-fade-transition>
</v-tab-item>
<!-- 指令面板标签页内容 -->
<v-tab-item v-show="activeTab === 'components'">
<v-card
class="rounded-lg"
variant="flat"
style="background-color: transparent"
>
<v-card-text class="pa-0">
<ComponentPanel :active="activeTab === 'components'" />
</v-card-text>
</v-card>
</v-tab-item>
<!-- 已安装的 MCP 服务器标签页内容 -->
<v-tab-item v-show="activeTab === 'mcp'">
<v-card
class="rounded-lg"
variant="flat"
style="background-color: transparent"
>
<v-card-text class="pa-0">
<McpServersSection />
</v-card-text>
</v-card>
</v-tab-item>
<!-- Skills 标签页内容 -->
<v-tab-item v-show="activeTab === 'skills'">
<v-card
class="rounded-lg"
variant="flat"
style="background-color: transparent"
>
<v-card-text class="pa-0">
<SkillsSection />
</v-card-text>
</v-card>
</v-tab-item>
<!-- 插件市场标签页内容 -->
<v-tab-item v-show="activeTab === 'market'">
<!-- 插件源管理区域 -->
<div class="mb-6">
<div
class="d-flex align-center pa-1 pl-2 pr-2"
style="
background: rgb(var(--v-theme-surface-variant), 0.1);
border-radius: 12px;
border: 1px solid
rgba(var(--v-border-color), var(--v-border-opacity));
"
>
<!-- 左侧源选择 -->
<div
class="d-flex align-center flex-grow-1"
style="min-width: 0"
>
<v-icon color="primary" class="ml-2 mr-2" size="small"
>mdi-source-branch</v-icon
>
<span
class="text-caption text-medium-emphasis text-uppercase font-weight-bold mr-2"
style="letter-spacing: 0.05em; white-space: nowrap"
>
{{ tm("market.source") }}
</span>
<v-menu offset-y>
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
variant="text"
class="text-capitalize px-1"
:ripple="false"
height="32"
>
<span
class="text-body-2 font-weight-medium text-high-emphasis text-truncate"
style="max-width: 200px"
>
{{
selectedSource
? customSources.find(
(s) => s.url === selectedSource,
)?.name
: tm("market.defaultSource")
}}
</span>
<v-icon
right
size="small"
class="ml-1 text-medium-emphasis"
>mdi-chevron-down</v-icon
>
<v-tooltip activator="parent" location="top">{{
selectedSource || tm("market.defaultOfficialSource")
}}</v-tooltip>
</v-btn>
</template>
<v-list density="compact" nav class="pa-2">
<v-list-subheader
class="font-weight-bold text-caption text-uppercase mb-1"
>
{{ tm("market.availableSources") }}
</v-list-subheader>
<v-list-item
:value="null"
@click="selectPluginSource(null)"
rounded="md"
color="primary"
:active="selectedSource === null"
>
<template v-slot:prepend>
<v-icon
icon="mdi-shield-check"
size="small"
class="mr-2"
></v-icon>
</template>
<v-list-item-title>{{
tm("market.defaultSource")
}}</v-list-item-title>
</v-list-item>
<v-divider
class="my-1"
v-if="customSources.length > 0"
></v-divider>
<v-list-item
v-for="source in customSources"
:key="source.url"
:value="source.url"
@click="selectPluginSource(source.url)"
rounded="md"
color="primary"
:active="selectedSource === source.url"
>
<template v-slot:prepend>
<v-icon
icon="mdi-link-variant"
size="small"
class="mr-2"
></v-icon>
</template>
<v-list-item-title>{{ source.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">{{
source.url
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-menu>
</div>
<!-- 垂直分隔线 -->
<div
style="
height: 20px;
width: 1px;
background-color: rgba(var(--v-border-color), 0.15);
margin: 0 8px;
"
></div>
<!--右侧操作按钮组-->
<div class="d-flex align-center">
<v-tooltip location="top" :text="tm('market.addSource')">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-plus"
size="small"
variant="text"
density="comfortable"
color="primary"
@click="addCustomSource"
></v-btn>
</template>
</v-tooltip>
<template v-if="selectedSourceObj">
<v-tooltip location="top" :text="tm('market.editSource')">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-pencil-outline"
size="small"
variant="text"
density="comfortable"
color="medium-emphasis"
class="ml-1"
@click="editCustomSource(selectedSourceObj)"
></v-btn>
</template>
</v-tooltip>
<v-tooltip location="top" :text="tm('market.removeSource')">
<template v-slot:activator="{ props }">
<v-btn
v-bind="props"
icon="mdi-trash-can-outline"
size="small"
variant="text"
density="comfortable"
color="error"
class="ml-1"
@click="removeCustomSource(selectedSourceObj)"
></v-btn>
</template>
</v-tooltip>
</template>
</div>
</div>
</div>
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small> -->
<!-- FAB Button -->
<v-tooltip :text="tm('market.installPlugin')" location="left">
<template v-slot:activator="{ props }">
<button
v-bind="props"
type="button"
class="v-btn v-btn--elevated v-btn--icon v-theme--PurpleThemeDark bg-darkprimary v-btn--density-default v-btn--size-x-large v-btn--variant-elevated fab-button"
style="
position: fixed;
right: 52px;
bottom: 52px;
z-index: 10000;
border-radius: 16px;
"
@click="dialog = true"
>
<span class="v-btn__overlay"></span>
<span class="v-btn__underlay"></span>
<span class="v-btn__content" data-no-activator="">
<i
class="mdi-plus mdi v-icon notranslate v-theme--PurpleThemeDark v-icon--size-default"
aria-hidden="true"
style="font-size: 32px"
></i>
</span>
</button>
</template>
</v-tooltip>
<div class="mt-4">
<div
class="d-flex align-center mb-2"
style="
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
"
>
<div class="d-flex align-center" style="gap: 6px">
<h2>
{{ tm("market.allPlugins") }}({{
filteredMarketPlugins.length
}})
</h2>
<v-btn
icon
variant="text"
@click="refreshPluginMarket"
:loading="refreshingMarket"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</div>
<div
class="d-flex align-center"
style="gap: 8px; flex-wrap: wrap"
>
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="5"
size="small"
density="comfortable"
></v-pagination>
<!-- 排序选择器 -->
<v-select
v-model="sortBy"
:items="[
{ title: tm('sort.default'), value: 'default' },
{ title: tm('sort.stars'), value: 'stars' },
{ title: tm('sort.author'), value: 'author' },
{ title: tm('sort.updated'), value: 'updated' },
]"
density="compact"
variant="outlined"
hide-details
style="max-width: 150px"
>
<template v-slot:prepend-inner>
<v-icon size="small">mdi-sort</v-icon>
</template>
</v-select>
<!-- 排序方向切换按钮 -->
<v-btn
icon
v-if="sortBy !== 'default'"
@click="sortOrder = sortOrder === 'desc' ? 'asc' : 'desc'"
variant="text"
density="compact"
>
<v-icon>{{
sortOrder === "desc"
? "mdi-sort-descending"
: "mdi-sort-ascending"
}}</v-icon>
<v-tooltip activator="parent" location="top">
{{
sortOrder === "desc"
? tm("sort.descending")
: tm("sort.ascending")
}}
</v-tooltip>
</v-btn>
<!-- <v-switch v-model="showPluginFullName" :label="tm('market.showFullName')" hide-details
density="compact" style="margin-left: 12px" /> -->
</div>
</div>
<v-row style="min-height: 26rem">
<v-col
v-for="plugin in paginatedPlugins"
:key="plugin.name"
cols="12"
md="6"
lg="4"
>
<v-card
class="rounded-lg d-flex flex-column plugin-card"
elevation="0"
style="height: 12rem; position: relative"
>
<!-- 推荐标记 -->
<v-chip
v-if="plugin?.pinned"
color="warning"
size="x-small"
label
style="
position: absolute;
right: 8px;
top: 8px;
z-index: 10;
height: 20px;
font-weight: bold;
"
>
🥳 推荐
</v-chip>
<v-card-text
style="
padding: 12px;
padding-bottom: 8px;
display: flex;
gap: 12px;
width: 100%;
flex: 1;
overflow: hidden;
"
>
<div style="flex-shrink: 0">
<img
:src="plugin?.logo || defaultPluginIcon"
:alt="plugin.name"
style="
height: 75px;
width: 75px;
border-radius: 8px;
object-fit: cover;
"
/>
</div>
<div
style="
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
"
>
<!-- Display Name -->
<div
class="font-weight-bold"
style="
margin-bottom: 4px;
line-height: 1.3;
font-size: 1.2rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
<span
style="overflow: hidden; text-overflow: ellipsis"
>
{{
plugin.display_name?.length
? plugin.display_name
: showPluginFullName
? plugin.name
: plugin.trimmedName
}}
</span>
</div>
<!-- Author with link -->
<div
class="d-flex align-center"
style="gap: 4px; margin-bottom: 6px"
>
<v-icon
icon="mdi-account"
size="x-small"
style="color: rgba(var(--v-theme-on-surface), 0.5)"
></v-icon>
<a
v-if="plugin?.social_link"
:href="plugin.social_link"
target="_blank"
class="text-subtitle-2 font-weight-medium"
style="
text-decoration: none;
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</a>
<span
v-else
class="text-subtitle-2 font-weight-medium"
style="
color: rgb(var(--v-theme-primary));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
"
>
{{ plugin.author }}
</span>
<div
class="d-flex align-center text-subtitle-2 ml-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-source-branch"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.version }}</span>
</div>
</div>
<!-- Description -->
<div class="text-caption plugin-description">
{{ plugin.desc }}
</div>
<!-- Stats: Stars & Updated & Version -->
<div
class="d-flex align-center"
style="gap: 8px; margin-top: auto"
>
<div
v-if="plugin.stars !== undefined"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-star"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{ plugin.stars }}</span>
</div>
<div
v-if="plugin.updated_at"
class="d-flex align-center text-subtitle-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
<v-icon
icon="mdi-clock-outline"
size="x-small"
style="margin-right: 2px"
></v-icon>
<span>{{
new Date(plugin.updated_at).toLocaleString()
}}</span>
</div>
</div>
</div>
</v-card-text>
<!-- Actions -->
<v-card-actions
style="gap: 6px; padding: 8px 12px; padding-top: 0"
>
<v-chip
v-for="tag in plugin.tags?.slice(0, 2)"
:key="tag"
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="x-small"
style="height: 20px"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<v-menu
v-if="plugin.tags && plugin.tags.length > 2"
open-on-hover
offset-y
>
<template v-slot:activator="{ props: menuProps }">
<v-chip
v-bind="menuProps"
color="grey"
label
size="x-small"
style="height: 20px; cursor: pointer"
>
+{{ plugin.tags.length - 2 }}
</v-chip>
</template>
<v-list density="compact">
<v-list-item
v-for="tag in plugin.tags.slice(2)"
:key="tag"
>
<v-chip
:color="tag === 'danger' ? 'error' : 'primary'"
label
size="small"
>
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn
v-if="plugin?.repo"
color="secondary"
size="x-small"
variant="tonal"
:href="plugin.repo"
target="_blank"
style="height: 24px"
>
<v-icon icon="mdi-github" start size="x-small"></v-icon>
{{ tm("buttons.viewRepo") }}
</v-btn>
<v-btn
v-if="!plugin?.installed"
color="primary"
size="x-small"
@click="handleInstallPlugin(plugin)"
variant="flat"
style="height: 24px"
>
{{ tm("buttons.install") }}
</v-btn>
<v-chip
v-else
color="success"
size="x-small"
label
style="height: 20px"
>
{{ tm("status.installed") }}
</v-chip>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<!-- 底部分页控件 -->
<div class="d-flex justify-center mt-4" v-if="totalPages > 1">
<v-pagination
v-model="currentPage"
:length="totalPages"
:total-visible="7"
size="small"
></v-pagination>
</div>
</div>
</v-tab-item>
<v-row v-if="loading_">
<v-col cols="12" class="d-flex justify-center">
<v-progress-circular
indeterminate
color="primary"
size="48"
></v-progress-circular>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<div class="d-flex align-center justify-center mt-4 mb-4 gap-4">
<v-btn
variant="text"
prepend-icon="mdi-book-open-variant"
href="https://astrbot.app/dev/plugin.html"
target="_blank"
color="primary"
class="text-none"
>
{{ tm("market.devDocs") }}
</v-btn>
<div
style="
height: 24px;
width: 1px;
background-color: rgba(var(--v-theme-on-surface), 0.12);
"
></div>
<v-btn
variant="text"
prepend-icon="mdi-github"
href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection"
target="_blank"
color="primary"
class="text-none"
>
{{ tm("market.submitRepo") }}
</v-btn>
</div>
</v-col>
</v-row>
<!-- 配置对话框 -->
<v-dialog v-model="configDialog" max-width="900">
<v-card>
<v-card-title class="text-h2 pa-4 pl-6 pb-0">{{
tm("dialogs.config.title")
}}</v-card-title>
<v-card-text>
<div style="max-height: 60vh; overflow-y: auto; padding-right: 8px">
<AstrBotConfig
v-if="extension_config.metadata"
:metadata="extension_config.metadata"
:iterable="extension_config.config"
:metadataKey="curr_namespace"
:pluginName="curr_namespace"
/>
<p v-else>{{ tm("dialogs.config.noConfig") }}</p>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="updateConfig">{{
tm("buttons.saveAndClose")
}}</v-btn>
<v-btn
color="blue-darken-1"
variant="text"
@click="configDialog = false"
>{{ tm("buttons.close") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 加载对话框 -->
<v-dialog v-model="loadingDialog.show" width="700" persistent>
<v-card>
<v-card-title class="text-h5">{{ loadingDialog.title }}</v-card-title>
<v-card-text style="max-height: calc(100vh - 200px); overflow-y: auto">
<v-progress-linear
v-if="loadingDialog.statusCode === 0"
indeterminate
color="primary"
class="mb-4"
></v-progress-linear>
<div v-if="loadingDialog.statusCode !== 0" class="py-8 text-center">
<v-icon
class="mb-6"
:color="loadingDialog.statusCode === 1 ? 'success' : 'error'"
:icon="
loadingDialog.statusCode === 1
? 'mdi-check-circle-outline'
: 'mdi-alert-circle-outline'
"
size="128"
></v-icon>
<div class="text-h4 font-weight-bold">{{ loadingDialog.result }}</div>
</div>
<div style="margin-top: 32px">
<h3>{{ tm("dialogs.loading.logs") }}</h3>
<ConsoleDisplayer
historyNum="10"
style="height: 200px; margin-top: 16px; margin-bottom: 24px"
>
</ConsoleDisplayer>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="text"
@click="resetLoadingDialog"
>{{ tm("buttons.close") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 插件信息对话框 -->
<v-dialog v-model="showPluginInfoDialog" width="1200">
<v-card>
<v-card-title class="text-h5"
>{{ selectedPlugin.name }} {{ tm("buttons.viewInfo") }}</v-card-title
>
<v-card-text>
<v-data-table
style="font-size: 17px"
:headers="plugin_handler_info_headers"
:items="selectedPlugin.handlers"
item-key="name"
>
<template v-slot:header.id="{ column }">
<p style="font-weight: bold">{{ column.title }}</p>
</template>
<template v-slot:item.event_type="{ item }">
{{ item.event_type }}
</template>
<template v-slot:item.desc="{ item }">
{{ item.desc }}
</template>
<template v-slot:item.type="{ item }">
<v-chip color="success">
{{ item.type }}
</v-chip>
</template>
<template v-slot:item.cmd="{ item }">
<span style="font-weight: bold">{{ item.cmd }}</span>
</template>
</v-data-table>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="text"
@click="showPluginInfoDialog = false"
>{{ tm("buttons.close") }}</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
:timeout="2000"
elevation="24"
:color="snack_success"
v-model="snack_show"
>
{{ snack_message }}
</v-snackbar>
<ReadmeDialog
v-model:show="readmeDialog.show"
:plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl"
/>
<!-- 插件更新日志对话框复用 ReadmeDialog -->
<ReadmeDialog
v-model:show="changelogDialog.show"
:plugin-name="changelogDialog.pluginName"
:repo-url="changelogDialog.repoUrl"
mode="changelog"
/>
<!-- 卸载插件确认对话框列表模式用 -->
<UninstallConfirmDialog
v-model="showUninstallDialog"
@confirm="handleUninstallConfirm"
/>
<!-- 更新全部插件确认对话框 -->
<v-dialog v-model="updateAllConfirmDialog.show" max-width="420">
<v-card class="rounded-lg">
<v-card-title class="d-flex align-center pa-4">
<v-icon color="warning" class="mr-2">mdi-update</v-icon>
{{ tm("dialogs.updateAllConfirm.title") }}
</v-card-title>
<v-card-text>
<p class="text-body-1">
{{ tm("dialogs.updateAllConfirm.message", { count: updatableExtensions.length }) }}
</p>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn
variant="text"
@click="cancelUpdateAll"
>{{ tm("buttons.cancel") }}</v-btn>
<v-btn
color="warning"
variant="flat"
@click="confirmUpdateAll"
>{{ tm("dialogs.updateAllConfirm.confirm") }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 指令冲突提示对话框 -->
<v-dialog v-model="conflictDialog.show" max-width="420">
<v-card class="rounded-lg">
<v-card-title class="d-flex align-center pa-4">
<v-icon color="warning" class="mr-2">mdi-alert-circle</v-icon>
{{ tm("conflicts.title") }}
</v-card-title>
<v-card-text class="px-4 pb-2">
<div class="d-flex align-center mb-3">
<v-chip
color="warning"
variant="tonal"
size="large"
class="font-weight-bold"
>
{{ conflictDialog.count }}
</v-chip>
<span class="ml-2 text-body-1">{{ tm("conflicts.pairs") }}</span>
</div>
<p
class="text-body-2"
style="color: rgba(var(--v-theme-on-surface), 0.7)"
>
{{ tm("conflicts.message") }}
</p>
</v-card-text>
<v-card-actions class="pa-4 pt-2">
<v-spacer></v-spacer>
<v-btn variant="text" @click="conflictDialog.show = false">{{
tm("conflicts.later")
}}</v-btn>
<v-btn color="warning" variant="flat" @click="handleConflictConfirm">
{{ tm("conflicts.goToManage") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 危险插件确认对话框 -->
<v-dialog v-model="dangerConfirmDialog" width="500" persistent>
<v-card>
<v-card-title class="text-h5 d-flex align-center">
<v-icon color="warning" class="mr-2">mdi-alert-circle</v-icon>
{{ tm("dialogs.danger_warning.title") }}
</v-card-title>
<v-card-text>
<div>{{ tm("dialogs.danger_warning.message") }}</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="cancelDangerInstall">
{{ tm("dialogs.danger_warning.cancel") }}
</v-btn>
<v-btn color="warning" @click="confirmDangerInstall">
{{ tm("dialogs.danger_warning.confirm") }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 上传插件对话框 -->
<v-dialog v-model="dialog" width="500">
<div
class="v-card v-card--density-default rounded-lg v-card--variant-elevated"
>
<div class="v-card__loader">
<v-progress-linear
:indeterminate="loading_"
color="primary"
height="2"
:active="loading_"
></v-progress-linear>
</div>
<v-card-title class="text-h3 pa-4 pb-0 pl-6">
{{ tm("dialogs.install.title") }}
</v-card-title>
<div class="v-card-text">
<v-tabs v-model="uploadTab" color="primary">
<v-tab value="file">{{ tm("dialogs.install.fromFile") }}</v-tab>
<v-tab value="url">{{ tm("dialogs.install.fromUrl") }}</v-tab>
</v-tabs>
<v-window v-model="uploadTab" class="mt-4">
<v-window-item value="file">
<div class="d-flex flex-column align-center justify-center pa-4">
<v-file-input
ref="fileInput"
v-model="upload_file"
:label="tm('upload.selectFile')"
accept=".zip"
hide-details
hide-input
class="d-none"
></v-file-input>
<v-btn
color="primary"
size="large"
prepend-icon="mdi-upload"
@click="$refs.fileInput.click()"
elevation="2"
>
{{ tm("buttons.selectFile") }}
</v-btn>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ tm("messages.supportedFormats") }}
</div>
<div v-if="upload_file" class="mt-4 text-center">
<v-chip
color="primary"
size="large"
closable
@click:close="upload_file = null"
>
{{ upload_file.name }}
<template v-slot:append>
<span class="text-caption ml-2"
>({{ (upload_file.size / 1024).toFixed(1) }}KB)</span
>
</template>
</v-chip>
</div>
</div>
</v-window-item>
<v-window-item value="url">
<div class="pa-4">
<v-text-field
v-model="extension_url"
:label="tm('upload.enterUrl')"
variant="outlined"
prepend-inner-icon="mdi-link"
hide-details
class="rounded-lg mb-4"
placeholder="https://github.com/username/repo"
></v-text-field>
<ProxySelector></ProxySelector>
</div>
</v-window-item>
</v-window>
</div>
<div class="v-card-actions">
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="dialog = false">{{
tm("buttons.cancel")
}}</v-btn>
<v-btn color="primary" variant="text" @click="newExtension">{{
tm("buttons.install")
}}</v-btn>
</div>
</div>
</v-dialog>
<!-- 添加/编辑自定义插件源对话框 -->
<v-dialog v-model="showSourceDialog" width="500">
<v-card>
<v-card-title class="text-h5">{{
editingSource ? tm("market.editSource") : tm("market.addSource")
}}</v-card-title>
<v-card-text>
<div class="pa-2">
<v-text-field
v-model="sourceName"
:label="tm('market.sourceName')"
variant="outlined"
prepend-inner-icon="mdi-rename-box"
hide-details
class="mb-4"
placeholder="我的插件源"
></v-text-field>
<v-text-field
v-model="sourceUrl"
:label="tm('market.sourceUrl')"
variant="outlined"
prepend-inner-icon="mdi-link"
hide-details
placeholder="https://example.com/plugins.json"
></v-text-field>
<div class="text-caption text-medium-emphasis mt-2">
{{ tm("messages.enterJsonUrl") }}
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="showSourceDialog = false">{{
tm("buttons.cancel")
}}</v-btn>
<v-btn color="primary" variant="text" @click="saveCustomSource">{{
tm("buttons.save")
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除插件源确认对话框 -->
<v-dialog v-model="showRemoveSourceDialog" width="400">
<v-card>
<v-card-title class="text-h5 d-flex align-center">
<v-icon color="warning" class="mr-2">mdi-alert-circle</v-icon>
{{ tm("dialogs.uninstall.title") }}
</v-card-title>
<v-card-text>
<div>{{ tm("market.confirmRemoveSource") }}</div>
<div v-if="sourceToRemove" class="mt-2">
<strong>{{ sourceToRemove.name }}</strong>
<div class="text-caption">{{ sourceToRemove.url }}</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="grey"
variant="text"
@click="showRemoveSourceDialog = false"
>{{ tm("buttons.cancel") }}</v-btn
>
<v-btn color="error" variant="text" @click="confirmRemoveSource">{{
tm("buttons.deleteSource")
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 强制更新确认对话框 -->
<v-dialog v-model="forceUpdateDialog.show" max-width="420">
<v-card class="rounded-lg">
<v-card-title class="text-h6 d-flex align-center">
<v-icon color="info" class="mr-2">mdi-information-outline</v-icon>
{{ tm("dialogs.forceUpdate.title") }}
</v-card-title>
<v-card-text>
{{ tm("dialogs.forceUpdate.message") }}
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="forceUpdateDialog.show = false">{{
tm("buttons.cancel")
}}</v-btn>
<v-btn color="primary" variant="flat" @click="confirmForceUpdate">{{
tm("dialogs.forceUpdate.confirm")
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.plugin-handler-item {
margin-bottom: 10px;
padding: 5px;
border-radius: 5px;
background-color: #f5f5f5;
}
.plugin-description {
color: rgba(var(--v-theme-on-surface), 0.6);
line-height: 1.3;
margin-bottom: 6px;
flex: 1;
overflow-y: hidden;
}
.plugin-card:hover .plugin-description {
overflow-y: auto;
}
.plugin-description::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.plugin-description::-webkit-scrollbar-track {
background: transparent;
}
.plugin-description::-webkit-scrollbar-thumb {
background-color: rgba(var(--v-theme-primary-rgb), 0.4);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.plugin-description::-webkit-scrollbar-thumb:hover {
background-color: rgba(var(--v-theme-primary-rgb), 0.6);
}
.fab-button {
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.fab-button:hover {
transform: translateY(-4px) scale(1.05);
box-shadow: 0 12px 20px rgba(var(--v-theme-primary), 0.4);
}
</style>