feat: custom plugin api source manager (#3956)

* feat: custom plugin api source manager

* fix: rename plugin source file in a safer way

* chore: turned the way of saving plugin source to backend and refacted some components

* style: clean up whitespace and improve logging message formatting

---------

Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
vmoranv
2025-12-08 00:32:50 +08:00
committed by GitHub
parent 37566182b0
commit f0fb447fbc
6 changed files with 633 additions and 127 deletions
+105 -51
View File
@@ -4,12 +4,16 @@ import os
import ssl
import traceback
from datetime import datetime
from dataclasses import dataclass
from typing import List, Optional
import hashlib
import aiohttp
import certifi
from quart import request
from astrbot.core import DEMO_MODE, file_token_service, logger
from astrbot.api import sp
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter
@@ -25,6 +29,13 @@ PLUGIN_UPDATE_CONCURRENCY = (
)
@dataclass
class RegistrySource:
urls: List[str]
cache_file: str
md5_url: Optional[str] # None means "no remote MD5, always treat cache as stale"
class PluginRoute(Route):
def __init__(
self,
@@ -45,6 +56,8 @@ class PluginRoute(Route):
"/plugin/on": ("POST", self.on_plugin),
"/plugin/reload": ("POST", self.reload_plugins),
"/plugin/readme": ("GET", self.get_plugin_readme),
"/plugin/source/get": ("GET", self.get_custom_source),
"/plugin/source/save": ("POST", self.save_custom_source),
}
self.core_lifecycle = core_lifecycle
self.plugin_manager = plugin_manager
@@ -84,22 +97,15 @@ class PluginRoute(Route):
custom = request.args.get("custom_registry")
force_refresh = request.args.get("force_refresh", "false").lower() == "true"
cache_file = "data/plugins.json"
if custom:
urls = [custom]
else:
urls = [
"https://api.soulter.top/astrbot/plugins",
"https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json",
]
# 构建注册表源信息
source = self._build_registry_source(custom)
# 如果不是强制刷新,先检查缓存是否有效
cached_data = None
if not force_refresh:
# 先检查MD5是否匹配,如果匹配则使用缓存
if await self._is_cache_valid(cache_file):
cached_data = self._load_plugin_cache(cache_file)
if await self._is_cache_valid(source):
cached_data = self._load_plugin_cache(source.cache_file)
if cached_data:
logger.debug("缓存MD5匹配,使用缓存的插件市场数据")
return Response().ok(cached_data).__dict__
@@ -109,7 +115,7 @@ class PluginRoute(Route):
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
for url in urls:
for url in source.urls:
try:
async with (
aiohttp.ClientSession(
@@ -128,11 +134,13 @@ class PluginRoute(Route):
logger.warning(f"远程插件市场数据为空: {url}")
continue # 继续尝试其他URL或使用缓存
logger.info("成功获取远程插件市场数据")
logger.info(
f"成功获取远程插件市场数据,包含 {len(remote_data)} 个插件"
)
# 获取最新的MD5并保存到缓存
current_md5 = await self._get_remote_md5()
current_md5 = await self._fetch_remote_md5(source.md5_url)
self._save_plugin_cache(
cache_file,
source.cache_file,
remote_data,
current_md5,
)
@@ -143,7 +151,7 @@ class PluginRoute(Route):
# 如果远程获取失败,尝试使用缓存数据
if not cached_data:
cached_data = self._load_plugin_cache(cache_file)
cached_data = self._load_plugin_cache(source.cache_file)
if cached_data:
logger.warning("远程插件市场数据获取失败,使用缓存数据")
@@ -151,24 +159,75 @@ class PluginRoute(Route):
return Response().error("获取插件列表失败,且没有可用的缓存数据").__dict__
async def _is_cache_valid(self, cache_file: str) -> bool:
"""检查缓存是否有效(基于MD5"""
try:
if not os.path.exists(cache_file):
return False
def _build_registry_source(self, custom_url: str | None) -> RegistrySource:
"""构建注册表源信息"""
if custom_url:
# 对自定义URL生成一个安全的文件名
url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
cache_file = f"data/plugins_custom_{url_hash}.json"
# 加载缓存文件
# 更安全的后缀处理方式
if custom_url.endswith(".json"):
md5_url = custom_url[:-5] + "-md5.json"
else:
md5_url = custom_url + "-md5.json"
urls = [custom_url]
else:
cache_file = "data/plugins.json"
md5_url = "https://api.soulter.top/astrbot/plugins-md5"
urls = [
"https://api.soulter.top/astrbot/plugins",
"https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json",
]
return RegistrySource(urls=urls, cache_file=cache_file, md5_url=md5_url)
def _load_cached_md5(self, cache_file: str) -> str | None:
"""从缓存文件中加载MD5"""
if not os.path.exists(cache_file):
return None
try:
with open(cache_file, encoding="utf-8") as f:
cache_data = json.load(f)
return cache_data.get("md5")
except Exception as e:
logger.warning(f"加载缓存MD5失败: {e}")
return None
cached_md5 = cache_data.get("md5")
async def _fetch_remote_md5(self, md5_url: str | None) -> str | None:
"""获取远程MD5"""
if not md5_url:
return None
try:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with (
aiohttp.ClientSession(
trust_env=True,
connector=connector,
) as session,
session.get(md5_url) as response,
):
if response.status == 200:
data = await response.json()
return data.get("md5", "")
except Exception as e:
logger.debug(f"获取远程MD5失败: {e}")
return None
async def _is_cache_valid(self, source: RegistrySource) -> bool:
"""检查缓存是否有效(基于MD5"""
try:
cached_md5 = self._load_cached_md5(source.cache_file)
if not cached_md5:
logger.debug("缓存文件中没有MD5信息")
return False
# 获取远程MD5
remote_md5 = await self._get_remote_md5()
if not remote_md5:
remote_md5 = await self._fetch_remote_md5(source.md5_url)
if remote_md5 is None:
logger.warning("无法获取远程MD5,将使用缓存")
return True # 如果无法获取远程MD5,认为缓存有效
@@ -182,30 +241,6 @@ class PluginRoute(Route):
logger.warning(f"检查缓存有效性失败: {e}")
return False
async def _get_remote_md5(self) -> str:
"""获取远程插件数据的MD5"""
try:
ssl_context = ssl.create_default_context(cafile=certifi.where())
connector = aiohttp.TCPConnector(ssl=ssl_context)
async with (
aiohttp.ClientSession(
trust_env=True,
connector=connector,
) as session,
session.get(
"https://api.soulter.top/astrbot/plugins-md5",
) as response,
):
if response.status == 200:
data = await response.json()
return data.get("md5", "")
logger.error(f"获取MD5失败,状态码:{response.status}")
return ""
except Exception as e:
logger.error(f"获取远程MD5失败: {e}")
return ""
def _load_plugin_cache(self, cache_file: str):
"""加载本地缓存的插件市场数据"""
try:
@@ -547,7 +582,7 @@ class PluginRoute(Route):
plugin_dir = os.path.join(
self.plugin_manager.plugin_store_path,
plugin_obj.root_dir_name,
plugin_obj.root_dir_name or "",
)
if not os.path.isdir(plugin_dir):
@@ -572,3 +607,22 @@ class PluginRoute(Route):
except Exception as e:
logger.error(f"/api/plugin/readme: {traceback.format_exc()}")
return Response().error(f"读取README文件失败: {e!s}").__dict__
async def get_custom_source(self):
"""获取自定义插件源"""
sources = await sp.global_get("custom_plugin_sources", [])
return Response().ok(sources).__dict__
async def save_custom_source(self):
"""保存自定义插件源"""
try:
data = await request.get_json()
sources = data.get("sources", [])
if not isinstance(sources, list):
return Response().error("sources fields must be a list").__dict__
await sp.global_put("custom_plugin_sources", sources)
return Response().ok(None, "保存成功").__dict__
except Exception as e:
logger.error(f"/api/plugin/source/save: {traceback.format_exc()}")
return Response().error(str(e)).__dict__
@@ -12,38 +12,40 @@
</template>
</v-radio>
</v-radio-group>
<div v-if="radioValue === '1'" style="margin-left: 16px;">
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
<template v-slot:label>
<div class="d-flex align-center">
<span class="mr-2">{{ proxy }}</span>
<div v-if="proxyStatus[idx]">
<v-chip
:color="proxyStatus[idx].available ? 'success' : 'error'"
size="x-small"
class="mr-1">
{{ proxyStatus[idx].available ? '可用' : '不可用' }}
</v-chip>
<v-chip
v-if="proxyStatus[idx].available"
color="info"
size="x-small">
{{ proxyStatus[idx].latency }}ms
</v-chip>
<v-expand-transition>
<div v-if="radioValue === '1'" style="margin-left: 16px;">
<v-radio-group v-model="githubProxyRadioControl" class="mt-2" hide-details="true">
<v-radio color="success" v-for="(proxy, idx) in githubProxies" :key="proxy" :value="idx">
<template v-slot:label>
<div class="d-flex align-center">
<span class="mr-2">{{ proxy }}</span>
<div v-if="proxyStatus[idx]">
<v-chip
:color="proxyStatus[idx].available ? 'success' : 'error'"
size="x-small"
class="mr-1">
{{ proxyStatus[idx].available ? '可用' : '不可用' }}
</v-chip>
<v-chip
v-if="proxyStatus[idx].available"
color="info"
size="x-small">
{{ proxyStatus[idx].latency }}ms
</v-chip>
</div>
</div>
</div>
</template>
</v-radio>
<v-radio color="primary" value="-1" label="自定义">
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
style="width: 100vw;" placeholder="自定义" hide-details="true">
</v-text-field>
</template>
</v-radio>
</v-radio-group>
</div>
</template>
</v-radio>
<v-radio color="primary" value="-1" label="自定义">
<template v-slot:label v-if="githubProxyRadioControl === '-1'">
<v-text-field density="compact" v-model="selectedGitHubProxy" variant="outlined"
style="width: 100vw;" placeholder="自定义" hide-details="true">
</v-text-field>
</template>
</v-radio>
</v-radio-group>
</div>
</v-expand-transition>
</template>
@@ -33,7 +33,8 @@
"back": "Back",
"selectFile": "Select File",
"refresh": "Refresh",
"updateAll": "Update All"
"updateAll": "Update All",
"deleteSource": "Delete Source"
},
"status": {
"enabled": "Enabled",
@@ -78,7 +79,27 @@
"allPlugins": "📦 All Extensions",
"showFullName": "Full Name",
"devDocs": "Extension Development Docs",
"submitRepo": "Submit Extension Repository"
"submitRepo": "Submit Extension Repository",
"customSource": "Custom Extension Source",
"source": "Source",
"availableSources": "Available Sources",
"sourceManagement": "Source Management",
"addSource": "Add Source",
"sourceName": "Source Name",
"sourceUrl": "Source URL",
"defaultSource": "Official Source",
"removeSource": "Remove Source",
"confirmRemoveSource": "Are you sure you want to remove this source?",
"sourceAdded": "Source added successfully",
"sourceRemoved": "Source removed successfully",
"sourceError": "Operation failed",
"selectSource": "Select Source",
"currentSource": "Current Source",
"editSource": "Edit Source",
"sourceUpdated": "Source updated successfully",
"defaultOfficialSource": "Default Official Source",
"sourceExists": "This source already exists",
"installPlugin": "Install Plugin"
},
"sort": {
"default": "Default",
@@ -144,7 +165,10 @@
"dontFillBoth": "Please don't fill in both extension URL and upload file",
"supportedFormats": "Supports .zip extension files",
"updateAllSuccess": "All upgradable extensions have been updated!",
"updateAllFailed": "{failed} of {total} extensions failed to update:"
"updateAllFailed": "{failed} of {total} extensions failed to update:",
"fillSourceNameAndUrl": "Please fill in the complete source name and URL",
"invalidUrl": "Please enter a valid URL",
"enterJsonUrl": "Please enter a URL that returns plugin list JSON data"
},
"upload": {
"fromFile": "Install from File",
@@ -33,7 +33,8 @@
"back": "返回",
"selectFile": "选择文件",
"refresh": "刷新",
"updateAll": "更新全部插件"
"updateAll": "更新全部插件",
"deleteSource": "删除源"
},
"status": {
"enabled": "启用",
@@ -78,7 +79,27 @@
"allPlugins": "📦 全部插件",
"showFullName": "完整名称",
"devDocs": "插件开发文档",
"submitRepo": "提交插件仓库"
"submitRepo": "提交插件仓库",
"customSource": "自定义插件源",
"source": "插件源",
"availableSources": "可用源",
"sourceManagement": "插件源管理",
"addSource": "添加插件源",
"sourceName": "源名称",
"sourceUrl": "源地址",
"defaultSource": "官方插件源",
"removeSource": "删除插件源",
"confirmRemoveSource": "确定要删除此插件源吗?",
"sourceAdded": "插件源添加成功",
"sourceRemoved": "插件源删除成功",
"sourceError": "操作失败",
"selectSource": "选择插件源",
"currentSource": "当前插件源",
"editSource": "编辑插件源",
"sourceUpdated": "插件源更新成功",
"defaultOfficialSource": "默认官方源",
"sourceExists": "该插件源已存在",
"installPlugin": "安装插件"
},
"sort": {
"default": "默认排序",
@@ -144,7 +165,10 @@
"dontFillBoth": "请不要同时填写插件链接和上传文件",
"supportedFormats": "支持 .zip 格式的插件文件",
"updateAllSuccess": "所有可更新的插件都已更新!",
"updateAllFailed": "有 {failed}/{total} 个插件更新失败:"
"updateAllFailed": "有 {failed}/{total} 个插件更新失败:",
"fillSourceNameAndUrl": "请填写完整的插件源名称和地址",
"invalidUrl": "请输入有效的URL地址",
"enterJsonUrl": "请输入返回插件列表JSON数据的URL地址"
},
"upload": {
"fromFile": "从文件安装",
+28 -20
View File
@@ -154,35 +154,43 @@ export const useCommonStore = defineStore({
this.startTime = res.data.data.start_time
})
},
async getPluginCollections(force = false) {
async getPluginCollections(force = false, customSource = null) {
// 获取插件市场数据
if (!force && this.pluginMarketData.length > 0) {
if (!force && this.pluginMarketData.length > 0 && !customSource) {
return Promise.resolve(this.pluginMarketData);
}
// 如果是强制刷新,添加 force_refresh 参数
const url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list';
// 构建URL
let url = force ? '/api/plugin/market_list?force_refresh=true' : '/api/plugin/market_list';
if (customSource) {
url += (url.includes('?') ? '&' : '?') + `custom_registry=${encodeURIComponent(customSource)}`;
}
return axios.get(url)
.then((res) => {
let data = []
for (let key in res.data.data) {
data.push({
"name": key,
"desc": res.data.data[key].desc,
"author": res.data.data[key].author,
"repo": res.data.data[key].repo,
"installed": false,
"version": res.data.data[key]?.version ? res.data.data[key].version : "未知",
"social_link": res.data.data[key]?.social_link,
"tags": res.data.data[key]?.tags ? res.data.data[key].tags : [],
"logo": res.data.data[key]?.logo ? res.data.data[key].logo : "",
"pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false,
"stars": res.data.data[key]?.stars ? res.data.data[key].stars : 0,
"updated_at": res.data.data[key]?.updated_at ? res.data.data[key].updated_at : "",
"display_name": res.data.data[key]?.display_name ? res.data.data[key].display_name : "",
})
if (res.data.data && typeof res.data.data === 'object') {
for (let key in res.data.data) {
const pluginData = res.data.data[key];
data.push({
"name": pluginData.name || key, // 优先使用插件数据中的name字段,否则使用键名
"desc": pluginData.desc,
"author": pluginData.author,
"repo": pluginData.repo,
"installed": false,
"version": pluginData?.version ? pluginData.version : "未知",
"social_link": pluginData?.social_link,
"tags": pluginData?.tags ? pluginData.tags : [],
"logo": pluginData?.logo ? pluginData.logo : "",
"pinned": pluginData?.pinned ? pluginData.pinned : false,
"stars": pluginData?.stars ? pluginData.stars : 0,
"updated_at": pluginData?.updated_at ? pluginData.updated_at : "",
"display_name": pluginData?.display_name ? pluginData.display_name : "",
})
}
}
this.pluginMarketData = data;
return data;
})
+413 -19
View File
@@ -67,6 +67,17 @@ 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);
@@ -549,6 +560,156 @@ const cancelDangerInstall = () => {
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 => {
@@ -660,7 +821,7 @@ const refreshPluginMarket = async () => {
refreshingMarket.value = true;
try {
// 强制刷新插件市场数据
const data = await commonStore.getPluginCollections(true);
const data = await commonStore.getPluginCollections(true, selectedSource.value);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
@@ -678,6 +839,9 @@ const refreshPluginMarket = async () => {
// 生命周期
onMounted(async () => {
await getExtensions();
// 加载自定义插件源
loadCustomSources();
// 检查是否有 open_config 参数
let urlParams;
@@ -697,7 +861,7 @@ onMounted(async () => {
}
try {
const data = await commonStore.getPluginCollections();
const data = await commonStore.getPluginCollections(false, selectedSource.value);
pluginMarketData.value = data;
trimExtensionName();
checkAlreadyInstalled();
@@ -957,11 +1121,146 @@ watch(marketSearch, (newVal) => {
<!-- 插件市场标签页内容 -->
<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> -->
<v-btn icon="mdi-plus" size="x-large" style="position: fixed; right: 52px; bottom: 52px; z-index: 10000"
@click="dialog = true" color="darkprimary">
</v-btn>
<!-- 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;">
@@ -1127,9 +1426,29 @@ watch(marketSearch, (newVal) => {
</v-col>
<v-col v-if="activeTab === 'market'" cols="12" md="12">
<small><a href="https://astrbot.app/dev/plugin.html">{{ tm('market.devDocs') }}</a></small> |
<small> <a href="https://github.com/AstrBotDevs/AstrBot_Plugins_Collection">{{ tm('market.submitRepo')
}}</a></small>
<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>
@@ -1249,10 +1568,15 @@ watch(marketSearch, (newVal) => {
<!-- 上传插件对话框 -->
<v-dialog v-model="dialog" width="500">
<v-card>
<v-card-title class="text-h5">{{ tm('dialogs.install.title') }}</v-card-title>
<v-card-text>
<v-tabs v-model="uploadTab">
<div class="v-card v-theme--PurpleThemeDark 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>
<div class="v-card-title text-h5">{{ tm('dialogs.install.title') }}</div>
<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>
@@ -1263,7 +1587,7 @@ watch(marketSearch, (newVal) => {
<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()">
<v-btn color="primary" size="large" prepend-icon="mdi-upload" @click="$refs.fileInput.click()" elevation="2">
{{ tm('buttons.selectFile') }}
</v-btn>
@@ -1285,19 +1609,79 @@ watch(marketSearch, (newVal) => {
<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
prepend-inner-icon="mdi-link" hide-details class="rounded-lg mb-4"
placeholder="https://github.com/username/repo"></v-text-field>
<div class="mt-4">
<ProxySelector></ProxySelector>
</div>
<ProxySelector></ProxySelector>
</div>
</v-window-item>
</v-window>
</v-card-text>
<v-card-actions>
</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>
@@ -1342,4 +1726,14 @@ watch(marketSearch, (newVal) => {
.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>