Merge pull request #1816 from AstrBotDevs/refactor-webui-merge-extension-page

refactor(webui): 将插件市场与插件管理合并
This commit is contained in:
Soulter
2025-06-15 22:51:55 +08:00
committed by GitHub
5 changed files with 418 additions and 883 deletions
+10 -2
View File
@@ -652,7 +652,11 @@ class PluginManager:
plugin_info = None
if plugin:
plugin_info = {"repo": plugin.repo, "readme": cleaned_content}
plugin_info = {
"repo": plugin.repo,
"readme": cleaned_content,
"name": plugin.name,
}
return plugin_info
@@ -847,6 +851,10 @@ class PluginManager:
plugin_info = None
if plugin:
plugin_info = {"repo": plugin.repo, "readme": readme_content}
plugin_info = {
"repo": plugin.repo,
"readme": readme_content,
"name": plugin.name,
}
return plugin_info
@@ -41,22 +41,17 @@ const sidebarItem: menu[] = [
to: '/config',
},
{
title: '插件管理',
title: '插件',
icon: 'mdi-puzzle',
to: '/extension'
},
{
title: '插件市场',
icon: 'mdi-storefront',
to: '/extension-marketplace'
},
{
title: '聊天',
icon: 'mdi-chat',
to: '/chat'
},
{
title: '对话数据',
title: '对话数据',
icon: 'mdi-database',
to: '/conversation'
},
+1 -1
View File
@@ -19,7 +19,7 @@ const MainRoutes = {
{
name: 'ExtensionMarketplace',
path: '/extension-marketplace',
component: () => import('@/views/ExtensionMarketplace.vue')
component: () => import('@/views/ExtensionPage.vue')
},
{
name: 'Platforms',
@@ -1,704 +0,0 @@
<script setup>
import ExtensionCard from '@/components/shared/ExtensionCard.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import axios from 'axios';
import { useCommonStore } from '@/stores/common';
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
</script>
<template>
<v-row>
<v-col cols="12" md="12" v-if="announcement">
<v-banner color="success" lines="one" :text="announcement" :stacked="false">
</v-banner>
</v-col>
<v-col cols="12" md="12">
<v-card>
<v-card-title>
<div class="pl-2 pt-2 d-flex align-center pe-2">
<h2> 插件市场</h2>
<v-btn icon size="small" style="margin-left: 8px" variant="plain" @click="jumpToPluginMarket()">
<v-icon size="small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start" max-width="500" open-delay="500">
<span>
如无法显示请单击此按钮跳转至插件市场复制想安装插件对应的
repo链接然后点击右下角 + 号安装或打开链接下载压缩包安装<br/>
如果因为网络问题安装失败点击设置页选择 GitHub 加速地址或前往仓库下载压缩包然后本地上传
</span>
</v-tooltip>
</v-btn>
<v-btn icon @click="isListView = !isListView" size="small" style="margin-left: auto;"
variant="plain">
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
<v-spacer/>
<v-text-field v-model="marketSearch" density="compact" label="Search"
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details
single-line></v-text-field>
</div>
</v-card-title>
<v-card-text>
<small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small>
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>🥳 推荐</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg"
market-mode="true" :highlight="true"
@install="extension_url=plugin.repo;
newExtension()"
@view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
</div>
<div v-if="isListView" class="mt-4">
<h2>📦 全部插件</h2>
<v-switch
v-model="showPluginFullName"
label="显示完整名称"
hide-details
density="compact"
style="margin-left: 12px"
/>
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center" style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;">
<img v-if="item.logo" :src="item.logo"
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
alt="logo">
<span v-if="item?.repo"><a :href="item?.repo"
style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
showPluginFullName ? item.name : item.trimmedName }}</a></span>
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
</div>
</template>
<template v-slot:item.desc="{ item }">
<div style="font-size: 13px;">
{{ item.desc }}
</div>
</template>
<template v-slot:item.author="{ item }">
<div style="font-size: 12px;">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author
}}</a></span>
<span v-else>{{ item.author }}</span>
</div>
</template>
<template v-slot:item.stars="{ item }">
<span>{{ item.stars }}</span>
</template>
<template v-slot:item.updated_at="{ item }">
<!-- 2025-04-28T16:39:27Z -->
<span>{{ new Date(item.updated_at).toLocaleString() }}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0">-</span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">
{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small"
variant="flat" @click="extension_url = item.repo; newExtension()">
<v-icon>mdi-download</v-icon></v-btn>
<v-btn v-else class="text-none mr-2" size="x-small" variant="flat" border
disabled><v-icon>mdi-check</v-icon></v-btn>
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
@click="open(item.repo)"><v-icon>mdi-help</v-icon></v-btn>
</template>
</v-data-table>
</v-col>
</div>
<div v-else class="mt-4">
<h2>📦 全部插件</h2>
<v-row style="margin-top: 16px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in filteredPluginMarketData">
<ExtensionCard :extension="plugin" market-mode="true">
</ExtensionCard>
</v-col>
</v-row>
</div>
</v-card-text>
</v-card>
</v-col>
<v-col style="margin-bottom: 16px;" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">插件开发文档</a></small> |
<small> <a href="https://github.com/Soulter/AstrBot_Plugins_Collection">提交插件仓库</a></small>
</v-col>
</v-row>
<v-dialog v-model="dialog" width="700">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" icon="mdi-plus" size="x-large" style="position: fixed; right: 52px; bottom: 52px;"
color="darkprimary">
</v-btn>
</template>
<v-card>
<v-card-title>
<span class="text-h5">安装插件</span>
</v-card-title>
<v-card-text>
<v-container>
<v-row>
<h3> GitHub 上在线下载</h3>
<v-col cols="12">
<small>请输入合法的 GitHub 仓库链接当前仅支持
GitHubhttps://github.com/Soulter/astrbot_plugin_aiocqhttp</small>
<v-text-field label="仓库链接" v-model="extension_url" variant="outlined"
required></v-text-field>
</v-col>
</v-row>
<v-row>
<h3>从本机上传 .zip 压缩包</h3>
<v-col cols="12">
<small>请保证插件文件存在压缩包根目录中的第一个文件夹中即类似于从 GitHub 仓库页上下载的 Zip 压缩包的格式</small>
<v-file-input label="选择文件" v-model="upload_file" accept=".zip" outlined
required></v-file-input>
</v-col>
</v-row>
</v-container>
<br>
<small>{{ status }}</small>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="dialog = false">
关闭
</v-btn>
<v-btn color="blue-darken-1" variant="text" :loading="loading_" @click="newExtension()">
安装
</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>
<WaitingForRestart ref="wfr"></WaitingForRestart>
<!-- README Dialog -->
<v-dialog v-model="readmeDialog.show" width="800" persistent>
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">插件说明文档</span>
<v-btn icon @click="readmeDialog.show = false">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text style="height: 70vh; overflow-y: auto;">
<v-btn color="primary" prepend-icon="mdi-open-in-new" @click="openReadmeInNewTab()" class="mt-4">
在GitHub中查看文档
</v-btn>
<div v-if="readmeDialog.content" class="markdown-body" v-html="renderMarkdown(readmeDialog.content)">
</div>
<div v-else-if="readmeDialog.error" class="d-flex flex-column align-center justify-center"
style="height: 100%;">
<v-icon size="64" color="error" class="mb-4">mdi-alert-circle-outline</v-icon>
<p class="text-body-1 text-center mb-4">{{ readmeDialog.error }}</p>
</div>
<div v-else class="d-flex flex-column align-center justify-center" style="height: 100%;">
<v-icon size="64" color="warning" class="mb-4">mdi-file-question-outline</v-icon>
<p class="text-body-1 text-center mb-4">该插件未提供文档链接或GitHub仓库地址<br>请查看插件市场或联系插件作者获取更多信息</p>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" @click="readmeDialog.show = false">
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
name: 'ExtensionPage',
components: {
ExtensionCard,
WaitingForRestart,
ConsoleDisplayer,
AstrBotConfig
},
data() {
return {
extension_data: {
"data": [],
"message": ""
},
extension_url: "",
status: "",
dialog: false,
snack_message: "",
snack_show: false,
snack_success: "success",
loading_: false,
upload_file: null,
pluginMarketData: [],
showPluginFullName: false,
loadingDialog: {
show: false,
title: "加载中...",
statusCode: 0, // 0: loading, 1: success, 2: error,
result: ""
},
readmeDialog: {
show: false,
url: null,
content: null,
error: null
},
announcement: "",
isListView: true,
pluginMarketHeaders: [
{ title: '名称', key: 'name', maxWidth: '200px' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '作者', key: 'author', maxWidth: '90px' },
{ title: 'Star数', key: 'stars', maxWidth: '80px' },
{ title: '最近更新', key: 'updated_at', maxWidth: '100px' },
{ title: '标签', key: 'tags', maxWidth: '100px' },
{ title: '操作', key: 'actions', sortable: false }
],
marketSearch: "",
commonStore: useCommonStore(),
filterKeys: ['name', 'desc', 'author']
}
},
computed: {
filteredPluginMarketData() {
if (!this.marketSearch) {
return this.pluginMarketData;
}
const search = this.marketSearch.toLowerCase();
return this.pluginMarketData.filter(plugin =>
this.filterKeys.some(key =>
plugin[key]?.toLowerCase().includes(search)
));
},
pinnedPlugins() {
return this.pluginMarketData.filter(plugin => plugin?.pinned);
}
},
mounted() {
// 获取本地插件数据
this.getExtensions();
// 获取插件市场数据
this.loading_ = true
this.commonStore.getPluginCollections().then((data) => {
this.pluginMarketData = data;
this.trimExtensionName();
this.checkAlreadyInstalled();
this.checkUpdate();
this.loading_ = false
}).catch((err) => {
console.error("获取插件市场数据失败:", err);
});
axios.get('https://api.soulter.top/astrbot-announcement-plugin-market').then((res) => {
let data = res.data.data;
this.announcement = data.text;
});
},
methods: {
open(link) {
if (link) {
window.open(link, '_blank');
}
},
jumpToPluginMarket() {
window.open('https://soulter.github.io/AstrBot_Plugins_Collection/plugins.json', '_blank');
},
toast(message, success) {
this.snack_message = message;
this.snack_show = true;
this.snack_success = success;
},
resetLoadingDialog() {
this.loadingDialog = {
show: false,
title: "加载中...",
statusCode: 0,
result: ""
}
},
onLoadingDialogResult(statusCode, result, timeToClose = 2000) {
this.loadingDialog.statusCode = statusCode;
this.loadingDialog.result = result;
if (timeToClose === -1) {
return
}
setTimeout(() => {
this.resetLoadingDialog()
}, timeToClose);
},
getExtensions() {
axios.get('/api/plugin/get').then((res) => {
this.extension_data = res.data;
this.trimExtensionName();
this.checkAlreadyInstalled();
this.checkUpdate()
});
},
trimExtensionName() {
this.pluginMarketData.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;
}
});
},
checkUpdate() {
// 创建在线插件的map
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
// 将在线插件信息存储到map中
this.pluginMarketData.forEach(plugin => {
if (plugin.repo) {
onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
}
onlinePluginsNameMap.set(plugin.name, plugin);
});
// 遍历本地插件列表
this.extension_data.data.forEach(extension => {
// 通过repo或name查找在线版本
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 !== "未知";
} else {
extension.has_update = false;
}
});
},
async getReadmeUrl(repoUrl) {
// 去掉 repoUrl 末尾的斜杠
repoUrl = repoUrl.replace(/\/+$/, '');
const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (!match) {
throw new Error("无效的 GitHub 仓库地址");
}
const owner = match[1];
const repo = match[2];
const apiUrl = `https://api.github.com/repos/${owner}/${repo}`;
try {
const res = await fetch(apiUrl);
const data = await res.json();
const branch = data?.default_branch || 'master';
return `${repoUrl}/blob/${branch}/README.md`;
} catch (error) {
console.error("获取默认分支失败,使用 master 作为默认:", error);
return `${repoUrl}/blob/master/README.md`;
}
},
async showReadmeDialog(res) {
this.readmeDialog.content = null;
this.readmeDialog.error = null;
if (res?.data?.data?.repo) {
this.readmeDialog.url = await this.getReadmeUrl(res.data.data.repo);
if (res.data.data.readme) {
this.readmeDialog.content = res.data.data.readme;
} else {
this.readmeDialog.error = "插件未提供README文档";
}
} else {
this.readmeDialog.url = null;
this.readmeDialog.error = "插件没有仓库信息或README文档";
}
this.readmeDialog.show = true;
},
async newExtension() {
if (this.extension_url === "" && this.upload_file === null) {
this.toast("请填写插件链接或上传插件文件", "error");
return;
}
if (this.extension_url !== "" && this.upload_file !== null) {
this.toast("请不要同时填写插件链接和上传插件文件", "error");
return;
}
this.loading_ = true;
this.loadingDialog.show = true;
if (this.upload_file !== null) {
this.toast("正在从文件安装插件", "primary");
const formData = new FormData();
formData.append('file', this.upload_file);
axios.post('/api/plugin/install-upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(async (res) => {
this.loading_ = false;
if (res.data.status === "error") {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.extension_data = res.data;
this.upload_file = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.getExtensions();
await this.showReadmeDialog(res);
}).catch((err) => {
this.loading_ = false;
this.onLoadingDialogResult(2, err, -1);
});
return;
} else {
this.toast("正在从链接 " + this.extension_url + " 安装插件...", "primary");
axios.post('/api/plugin/install',
{
url: this.extension_url,
proxy: localStorage.getItem('selectedGitHubProxy') || ""
}).then(async (res) => {
this.loading_ = false;
this.toast(res.data.message, res.data.status === "ok" ? "success" : "error");
if (res.data.status === "error") {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.extension_data = res.data;
this.extension_url = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.getExtensions();
await this.showReadmeDialog(res);
}).catch((err) => {
this.loading_ = false;
this.toast("安装插件失败: " + err, "error");
this.onLoadingDialogResult(2, err, -1);
});
}
},
checkAlreadyInstalled() {
// 创建已安装插件的仓库和名称集合 统一格式
const installedRepos = new Set(this.extension_data.data.map(ext => ext.repo?.toLowerCase()));
const installedNames = new Set(this.extension_data.data.map(ext => ext.name));
// 遍历检查安装状态
for (let i = 0; i < this.pluginMarketData.length; i++) {
const plugin = this.pluginMarketData[i];
plugin.installed = installedRepos.has(plugin.repo?.toLowerCase()) || installedNames.has(plugin.name);
}
// 将已安装的插件移动到最后面
let installed = [];
let notInstalled = [];
for (let i = 0; i < this.pluginMarketData.length; i++) {
if (this.pluginMarketData[i].installed) {
installed.push(this.pluginMarketData[i]);
} else {
notInstalled.push(this.pluginMarketData[i]);
}
}
this.pluginMarketData = notInstalled.concat(installed);
},
openReadmeInNewTab() {
if (this.readmeDialog.url) {
window.open(this.readmeDialog.url, '_blank');
}
},
renderMarkdown(content) {
if (!content) return '';
// Configure marked with highlight.js for syntax highlighting
marked.setOptions({
highlight: function (code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
},
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert \n to <br>
headerIds: true, // Add id attributes to headers
mangle: false // Don't mangle email addresses
});
return marked(content);
},
},
}
</script>
<style>
.markdown-body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
padding: 8px 0;
color: var(--v-theme-secondaryText);
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
.markdown-body h1 {
font-size: 2em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body h2 {
font-size: 1.5em;
border-bottom: 1px solid var(--v-theme-border);
padding-bottom: 0.3em;
}
.markdown-body p {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
background-color: var(--v-theme-codeBg);
border-radius: 3px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
.markdown-body pre {
padding: 16px;
overflow: auto;
font-size: 85%;
line-height: 1.45;
background-color: var(--v-theme-containerBg);
border-radius: 3px;
margin-bottom: 16px;
}
.markdown-body pre code {
background-color: transparent;
padding: 0;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 2em;
margin-bottom: 16px;
}
.markdown-body img {
max-width: 100%;
margin: 8px 0;
box-sizing: border-box;
background-color: var(--v-theme-background);
border-radius: 3px;
}
.markdown-body blockquote {
padding: 0 1em;
color: var(--v-theme-secondaryText);
border-left: 0.25em solid var(--v-theme-border);
margin-bottom: 16px;
}
.markdown-body a {
color: var(--v-theme-primary);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
overflow: auto;
margin-bottom: 16px;
}
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid var(--v-theme-background);
}
.markdown-body table tr {
background-color: var(--v-theme-surface);
border-top: 1px solid var(--v-theme-border);
}
.markdown-body table tr:nth-child(2n) {
background-color: var(--v-theme-background);
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: var(--v-theme-containerBg);
border: 0;
}
</style>
+405 -169
View File
@@ -1,16 +1,16 @@
<script setup>
import ExtensionCard from '@/components/shared/ExtensionCard.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import axios from 'axios';
import { useCommonStore } from '@/stores/common';
// 将所有状态和方法迁移到 setup 语法中
import { ref, computed, onMounted, reactive } from 'vue';
const commonStore = useCommonStore();
const activeTab = ref('installed');
const extension_data = reactive({
data: [],
message: ""
@@ -34,7 +34,6 @@ const loadingDialog = reactive({
const showPluginInfoDialog = ref(false);
const selectedPlugin = ref({});
const curr_namespace = ref("");
const wfr = ref(null);
const readmeDialog = reactive({
show: false,
@@ -55,6 +54,14 @@ const isListView = ref(false);
const pluginSearch = ref("");
const loading_ = ref(false);
// 插件市场相关
const extension_url = ref("");
const dialog = ref(false);
const upload_file = ref(null);
const showPluginFullName = ref(false);
const marketSearch = ref("");
const filterKeys = ['name', 'desc', 'author'];
const plugin_handler_info_headers = [
{ title: '行为类型', key: 'event_type_h' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
@@ -72,6 +79,19 @@ const pluginHeaders = [
{ title: '操作', key: 'actions', sortable: false, width: '220px' }
];
// 插件市场表头
const pluginMarketHeaders = [
{ title: '名称', key: 'name', maxWidth: '200px' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '作者', key: 'author', maxWidth: '90px' },
{ title: 'Star数', key: 'stars', maxWidth: '80px' },
{ title: '最近更新', key: 'updated_at', maxWidth: '100px' },
{ title: '标签', key: 'tags', maxWidth: '100px' },
{ title: '操作', key: 'actions', sortable: false }
];
// 过滤要显示的插件
const filteredExtensions = computed(() => {
if (!showReserved.value) {
@@ -94,6 +114,10 @@ const filteredPlugins = computed(() => {
});
});
const pinnedPlugins = computed(() => {
return pluginMarketData.value.filter(plugin => plugin?.pinned);
});
// 方法
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
@@ -385,6 +409,116 @@ const toggleAllPluginsForPlatform = (platformName) => {
});
};
const open = (link) => {
if (link) {
window.open(link, '_blank');
}
};
// 插件市场显示完整插件名称
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 installedRepos = new Set(extension_data.data.map(ext => ext.repo?.toLowerCase()));
const installedNames = new Set(extension_data.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("请填写插件链接或上传插件文件", "error");
return;
}
if (extension_url.value !== "" && upload_file.value !== null) {
toast("请不要同时填写插件链接和上传插件文件", "error");
return;
}
loading_.value = true;
loadingDialog.show = true;
if (upload_file.value !== null) {
toast("正在从文件安装插件", "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
});
}).catch((err) => {
loading_.value = false;
onLoadingDialogResult(2, err, -1);
});
} else {
toast("正在从链接 " + extension_url.value + " 安装插件...", "primary");
axios.post('/api/plugin/install',
{
url: extension_url.value,
proxy: localStorage.getItem('selectedGitHubProxy') || ""
}).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
});
}).catch((err) => {
loading_.value = false;
toast("安装插件失败: " + err, "error");
onLoadingDialogResult(2, err, -1);
});
}
};
// 生命周期
onMounted(async () => {
await getExtensions();
@@ -399,11 +533,15 @@ onMounted(async () => {
try {
const data = await commonStore.getPluginCollections();
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
checkUpdate();
} catch (err) {
console.error("获取插件市场数据失败:", err);
}
});
</script>
<template>
@@ -417,205 +555,300 @@ onMounted(async () => {
</div>
</template>
<v-card-title class="text-h4 font-weight-bold">
已安装的插件
AstrBot 插件
</v-card-title>
<v-card-subtitle class="text-subtitle-1 mt-1 text-medium-emphasis">
管理已经安装的所有插件
管理安装 AstrBot 插件
</v-card-subtitle>
</v-card-item>
<v-card-text class="pt-2">
<v-row class="mb-4">
<v-col cols="12" sm="6" md="6" class="d-flex align-center">
<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-card-text>
<v-btn class="ml-2" @click="toggleShowReserved" prepend-icon="mdi-eye-settings-outline"
:color="showReserved ? 'primary' : undefined" :variant="showReserved ? 'flat' : 'outlined'">
{{ showReserved ? '隐藏系统插件' : '显示系统插件' }}
</v-btn>
<v-btn class="ml-2" prepend-icon="mdi-tune-vertical" color="primary" variant="outlined"
@click="getPlatformEnableConfig">
平台命令配置
</v-btn>
</v-col>
<v-col cols="12" sm="auto" md="6" class="ml-auto">
<div class="search-container rounded-lg">
<v-text-field v-model="pluginSearch" density="compact" label="Search" prepend-inner-icon="mdi-magnify"
variant="solo-filled" flat hide-details single-line></v-text-field>
</div>
<div class="d-flex align-center mb-2" style="justify-content: space-between;">
<v-tabs v-model="activeTab" color="primary" class="mb-4">
<v-tab value="installed">
<v-icon class="mr-2">mdi-puzzle</v-icon>
已安装插件
</v-tab>
<v-tab value="market">
<v-icon class="mr-2">mdi-store</v-icon>
插件市场
</v-tab>
</v-tabs>
<v-text-field v-if="activeTab == 'market'" style="max-width: 300px;" v-model="marketSearch" density="compact"
label="Search" prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details
single-line></v-text-field>
<v-text-field v-else style="max-width: 300px;" v-model="pluginSearch" density="compact" label="Search" prepend-inner-icon="mdi-magnify"
variant="solo-filled" flat hide-details single-line></v-text-field>
<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-badge dot color="error" floating></v-badge>
</div>
<!-- 已安装插件标签页内容 -->
<v-tab-item v-show="activeTab === 'installed'">
<v-row class="mb-4">
<v-col cols="12" sm="6" md="6" class="d-flex align-center">
<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>
</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>
错误信息
</v-card-title>
<v-card-text>
<p class="text-body-1">{{ extension_data.message }}</p>
<p class="text-caption mt-2">详情请检查控制台</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="isActive.value = false">关闭</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</v-col>
</v-row>
<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-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">加载中...</span>
</v-row>
<v-btn class="ml-2" @click="toggleShowReserved" prepend-icon="mdi-eye-settings-outline"
:color="showReserved ? 'primary' : undefined" :variant="showReserved ? 'flat' : 'outlined'">
{{ showReserved ? '隐藏系统插件' : '显示系统插件' }}
</v-btn>
<v-btn class="ml-2" prepend-icon="mdi-tune-vertical" color="primary" variant="outlined"
@click="getPlatformEnableConfig">
平台命令配置
</v-btn>
</v-col>
<v-col cols="12" sm="auto" md="6" 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-badge dot color="error" floating></v-badge>
</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>
错误信息
</v-card-title>
<v-card-text>
<p class="text-body-1">{{ extension_data.message }}</p>
<p class="text-caption mt-2">详情请检查控制台</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="isActive.value = false">关闭</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</v-col>
</v-row>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2">
<div>
<div class="text-subtitle-1 font-weight-medium">{{ 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">系统</v-chip>
<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">加载中...</span>
</v-row>
</template>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2">
<div>
<div class="text-subtitle-1 font-weight-medium">{{ 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">系统</v-chip>
</div>
</div>
</div>
</div>
</template>
</template>
<template v-slot:item.desc="{ item }">
<div class="text-body-2 text-medium-emphasis">{{ item.desc }}</div>
</template>
<template v-slot:item.desc="{ item }">
<div class="text-body-2 text-medium-emphasis">{{ 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>有新版本: {{ item.online_version }}</span>
</v-tooltip>
</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>有新版本: {{ 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.author="{ item }">
<div class="text-body-2">{{ item.author }}</div>
</template>
<template v-slot:item.status="{ item }">
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
:variant="item.activated ? 'flat' : 'outlined'">
{{ item.activated ? '启用' : '禁用' }}
</v-chip>
</template>
<template v-slot:item.status="{ item }">
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
:variant="item.activated ? 'flat' : 'outlined'">
{{ item.activated ? '启用' : '禁用' }}
</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">点击启用</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">点击禁用</v-tooltip>
</v-btn>
<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">点击启用</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">点击禁用</v-tooltip>
</v-btn>
<v-btn icon size="small" color="info" @click="reloadPlugin(item.name)">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">重载</v-tooltip>
</v-btn>
<v-btn icon size="small" color="info" @click="reloadPlugin(item.name)">
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">重载</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">配置</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">配置</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="showPluginInfo(item)">
<v-icon>mdi-information</v-icon>
<v-tooltip activator="parent" location="top">行为</v-tooltip>
</v-btn>
<v-btn icon size="small" @click="showPluginInfo(item)">
<v-icon>mdi-information</v-icon>
<v-tooltip activator="parent" location="top">行为</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">文档</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">文档</v-tooltip>
</v-btn>
<v-btn icon size="small" color="warning"
@click="updateExtension(item.name)" :v-show="item.has_update">
<v-icon>mdi-update</v-icon>
<v-tooltip activator="parent" location="top">更新</v-tooltip>
</v-btn>
<v-btn icon size="small" color="warning" @click="updateExtension(item.name)"
:v-show="item.has_update">
<v-icon>mdi-update</v-icon>
<v-tooltip activator="parent" location="top">更新</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">卸载</v-tooltip>
</v-btn>
</v-btn-group>
<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">卸载</v-tooltip>
</v-btn>
</v-btn-group>
</div>
</template>
</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">暂无插件</div>
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</div>
</div>
</template>
</v-data-table>
</v-card>
</div>
<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">暂无插件</div>
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</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-8">
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
<div class="text-h5 mb-2">暂无插件</div>
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</div>
</v-col>
</v-row>
<!-- 卡片视图 -->
<div v-else>
<v-row v-if="filteredPlugins.length === 0" class="text-center">
<v-col cols="12" class="pa-8">
<v-icon size="64" color="info" class="mb-4">mdi-puzzle-outline</v-icon>
<div class="text-h5 mb-2">暂无插件</div>
<div class="text-body-1 mb-4">尝试安装插件或者显示系统插件</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-4">
<ExtensionCard :extension="extension" class="h-120 rounded-lg"
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
<v-row>
<v-col cols="12" md="6" lg="4" v-for="extension in filteredPlugins" :key="extension.name"
class="pb-4">
<ExtensionCard :extension="extension" class="h-120 rounded-lg"
@configure="openExtensionConfig(extension.name)" @uninstall="uninstallExtension(extension.name)"
@update="updateExtension(extension.name)" @reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)" @view-readme="viewReadme(extension)">
</ExtensionCard>
</v-col>
</v-row>
</div>
</v-fade-transition>
</v-tab-item>
<!-- 插件市场标签页内容 -->
<v-tab-item v-show="activeTab === 'market'">
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small> -->
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>🥳 推荐</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins" :key="plugin.name">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg" market-mode="true" :highlight="true"
@install="extension_url = plugin.repo; newExtension()" @view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
</div>
</v-fade-transition>
<div class="mt-4">
<div class="d-flex align-center mb-2" style="justify-content: space-between;">
<h2>📦 全部插件</h2>
<v-switch v-model="showPluginFullName" label="完整名称" hide-details density="compact"
style="margin-left: 12px" />
</div>
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
:loading="loading_" v-model:search="marketSearch" :filter-keys="filterKeys">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center"
style="overflow-x: auto; scrollbar-width: thin; scrollbar-track-color: transparent;">
<img v-if="item.logo" :src="item.logo"
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
alt="logo">
<span v-if="item?.repo"><a :href="item?.repo"
style="color: var(--v-theme-primaryText, #000); text-decoration:none">{{
showPluginFullName ? item.name : item.trimmedName }}</a></span>
<span v-else>{{ showPluginFullName ? item.name : item.trimmedName }}</span>
</div>
</template>
<template v-slot:item.desc="{ item }">
<div style="font-size: 13px;">
{{ item.desc }}
</div>
</template>
<template v-slot:item.author="{ item }">
<div style="font-size: 12px;">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author }}</a></span>
<span v-else>{{ item.author }}</span>
</div>
</template>
<template v-slot:item.stars="{ item }">
<span>{{ item.stars }}</span>
</template>
<template v-slot:item.updated_at="{ item }">
<span>{{ new Date(item.updated_at).toLocaleString() }}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0">-</span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">
{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small" variant="flat"
@click="extension_url = item.repo; newExtension()">
<v-icon>mdi-download</v-icon></v-btn>
<v-btn v-else class="text-none mr-2" size="x-small" variant="flat" border
disabled><v-icon>mdi-check</v-icon></v-btn>
<v-btn class="text-none mr-2" size="x-small" variant="flat" border
@click="open(item.repo)"><v-icon>mdi-help</v-icon></v-btn>
</template>
</v-data-table>
</v-col>
</div>
</v-tab-item>
<v-row v-if="loading_">
<v-col cols="12" class="d-flex justify-center">
@@ -625,6 +858,11 @@ onMounted(async () => {
</v-card-text>
</v-card>
</v-col>
<v-col v-if="activeTab === 'market'" style="margin-bottom: 16px;" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">插件开发文档</a></small> |
<small> <a href="https://github.com/Soulter/AstrBot_Plugins_Collection">提交插件仓库</a></small>
</v-col>
</v-row>
<!-- 插件平台配置对话框 -->
@@ -796,8 +1034,6 @@ onMounted(async () => {
{{ snack_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl" />
</template>