mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
2713 lines
89 KiB
Vue
2713 lines
89 KiB
Vue
<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>
|