From f0fb447fbc68f107f11cdf35639166354507afe8 Mon Sep 17 00:00:00 2001
From: vmoranv <98155299+vmoranv@users.noreply.github.com>
Date: Mon, 8 Dec 2025 00:32:50 +0800
Subject: [PATCH] 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>
---
astrbot/dashboard/routes/plugin.py | 156 ++++---
.../src/components/shared/ProxySelector.vue | 64 +--
.../locales/en-US/features/extension.json | 30 +-
.../locales/zh-CN/features/extension.json | 30 +-
dashboard/src/stores/common.js | 48 +-
dashboard/src/views/ExtensionPage.vue | 432 +++++++++++++++++-
6 files changed, 633 insertions(+), 127 deletions(-)
diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py
index f2a35dfe1..bcb02bba5 100644
--- a/astrbot/dashboard/routes/plugin.py
+++ b/astrbot/dashboard/routes/plugin.py
@@ -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__
diff --git a/dashboard/src/components/shared/ProxySelector.vue b/dashboard/src/components/shared/ProxySelector.vue
index d45a0f520..d863fcd85 100644
--- a/dashboard/src/components/shared/ProxySelector.vue
+++ b/dashboard/src/components/shared/ProxySelector.vue
@@ -12,38 +12,40 @@
-
-
-
-
-
-
{{ proxy }}
-
-
- {{ proxyStatus[idx].available ? '可用' : '不可用' }}
-
-
- {{ proxyStatus[idx].latency }}ms
-
+
+
+
+
+
+
+
{{ proxy }}
+
+
+ {{ proxyStatus[idx].available ? '可用' : '不可用' }}
+
+
+ {{ proxyStatus[idx].latency }}ms
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index b1ec35191..ab8d7b855 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -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",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 6b1521dcc..e31057fd1 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -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": "从文件安装",
diff --git a/dashboard/src/stores/common.js b/dashboard/src/stores/common.js
index 17d760bcc..fa8dde58b 100644
--- a/dashboard/src/stores/common.js
+++ b/dashboard/src/stores/common.js
@@ -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;
})
diff --git a/dashboard/src/views/ExtensionPage.vue b/dashboard/src/views/ExtensionPage.vue
index 37fcc7c2e..a83631c42 100644
--- a/dashboard/src/views/ExtensionPage.vue
+++ b/dashboard/src/views/ExtensionPage.vue
@@ -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) => {
+
+
+
+
+
+
+ mdi-source-branch
+
+ {{ tm('market.source') }}
+
+
+
+
+
+
+ {{ selectedSource ? customSources.find(s => s.url === selectedSource)?.name : tm('market.defaultSource') }}
+
+ mdi-chevron-down
+ {{ selectedSource || tm('market.defaultOfficialSource') }}
+
+
+
+
+ {{ tm('market.availableSources') }}
+
+
+
+
+
+ {{ tm('market.defaultSource') }}
+
+
+
+
+
+
+
+
+ {{ source.name }}
+ {{ source.url }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
@@ -1127,9 +1426,29 @@ watch(marketSearch, (newVal) => {
- {{ tm('market.devDocs') }} |
- {{ tm('market.submitRepo')
- }}
+
+
+ {{ tm('market.devDocs') }}
+
+
+
+ {{ tm('market.submitRepo') }}
+
+
@@ -1249,10 +1568,15 @@ watch(marketSearch, (newVal) => {
-
- {{ tm('dialogs.install.title') }}
-
-
+
+
+
+
+
+
{{ tm('dialogs.install.title') }}
+
+
+
{{ tm('dialogs.install.fromFile') }}
{{ tm('dialogs.install.fromUrl') }}
@@ -1263,7 +1587,7 @@ watch(marketSearch, (newVal) => {
-
+
{{ tm('buttons.selectFile') }}
@@ -1285,19 +1609,79 @@ watch(marketSearch, (newVal) => {
-
-
+
+
+
{{ tm('buttons.cancel') }}
{{ tm('buttons.install') }}
+
+
+
+
+
+
+
+ {{ editingSource ? tm('market.editSource') : tm('market.addSource') }}
+
+
+
+
+
+
+
+ {{ tm('messages.enterJsonUrl') }}
+
+
+
+
+
+ {{ tm('buttons.cancel') }}
+ {{ tm('buttons.save') }}
+
+
+
+
+
+
+
+
+ mdi-alert-circle
+ {{ tm('dialogs.uninstall.title') }}
+
+
+ {{ tm('market.confirmRemoveSource') }}
+
+
{{ sourceToRemove.name }}
+
{{ sourceToRemove.url }}
+
+
+
+
+ {{ tm('buttons.cancel') }}
+ {{ tm('buttons.deleteSource') }}
@@ -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);
+}