diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py
index d151bbe6f6..6cf47ec824 100644
--- a/astrbot/dashboard/routes/plugin.py
+++ b/astrbot/dashboard/routes/plugin.py
@@ -7,6 +7,7 @@
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
+from urllib.parse import urlparse
import aiohttp
import certifi
@@ -737,8 +738,26 @@ async def on_plugin(self):
async def get_plugin_readme(self):
plugin_name = request.args.get("name")
- logger.debug(f"正在获取插件 {plugin_name} 的README文件内容")
+ repo_url = request.args.get("repo")
+ logger.debug(f"正在获取插件 {plugin_name} 的README文件内容, repo: {repo_url}")
+ # 如果提供了 repo_url,优先从远程获取
+ if repo_url:
+ try:
+ readme_content = await self._fetch_remote_readme(repo_url)
+ if readme_content:
+ return (
+ Response()
+ .ok({"content": readme_content}, "成功获取README内容")
+ .__dict__
+ )
+ else:
+ return Response().error("无法从远程仓库获取README文件").__dict__
+ except Exception as e:
+ logger.error(f"从远程获取README失败: {traceback.format_exc()}")
+ return Response().error(f"获取README失败: {e!s}").__dict__
+
+ # 否则从本地获取
if not plugin_name:
logger.warning("插件名称为空")
return Response().error("插件名称不能为空").__dict__
@@ -791,6 +810,53 @@ async def get_plugin_readme(self):
logger.error(f"/api/plugin/readme: {traceback.format_exc()}")
return Response().error(f"读取README文件失败: {e!s}").__dict__
+ async def _fetch_remote_readme(self, repo_url: str) -> str | None:
+ """从远程GitHub仓库获取README内容"""
+ # 解析GitHub仓库URL
+ # 支持格式: https://github.com/owner/repo 或 https://github.com/owner/repo.git
+ repo_url = repo_url.rstrip("/").removesuffix(".git")
+
+ # 使用 urlparse 严格解析 URL,校验域名和路径
+ parsed = urlparse(repo_url)
+
+ # 仅支持 GitHub 仓库链接
+ if parsed.netloc.lower() != "github.com":
+ return None
+
+ # 提取路径中的 owner 和 repo,要求至少有两个段
+ path_parts = [part for part in parsed.path.strip("/").split("/") if part]
+ if len(path_parts) < 2:
+ return None
+
+ owner, repo = path_parts[0], path_parts[1]
+
+ # 尝试多种README文件名
+ readme_names = ["README.md", "readme.md", "README.MD", "Readme.md"]
+
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
+ connector = aiohttp.TCPConnector(ssl=ssl_context)
+
+ async with aiohttp.ClientSession(
+ trust_env=True, connector=connector, timeout=aiohttp.ClientTimeout(total=10)
+ ) as session:
+ # 尝试从不同分支获取
+ branches = ["main", "master"]
+ for branch in branches:
+ for readme_name in readme_names:
+ # 使用GitHub raw content URL
+ raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{readme_name}"
+ try:
+ async with session.get(raw_url) as response:
+ if response.status == 200:
+ content = await response.text()
+ logger.debug(f"成功从 {raw_url} 获取README")
+ return content
+ except Exception as e:
+ logger.debug(f"从 {raw_url} 获取失败: {e}")
+ continue
+
+ return None
+
async def get_plugin_changelog(self):
"""获取插件更新日志
diff --git a/dashboard/src/components/extension/MarketPluginCard.vue b/dashboard/src/components/extension/MarketPluginCard.vue
index 445f07b8cf..4ee7231035 100644
--- a/dashboard/src/components/extension/MarketPluginCard.vue
+++ b/dashboard/src/components/extension/MarketPluginCard.vue
@@ -20,7 +20,7 @@ const props = defineProps({
},
});
-const emit = defineEmits(["install"]);
+const emit = defineEmits(["install", "viewReadme"]);
const normalizePlatformList = (platforms) => {
if (!Array.isArray(platforms)) return [];
@@ -35,6 +35,30 @@ const handleInstall = (plugin) => {
emit("install", plugin);
};
+const handleViewReadme = (plugin) => {
+ emit("viewReadme", plugin);
+};
+
+// 从 repo URL 提取作者主页链接
+const authorHomepageUrl = computed(() => {
+ const repoUrl = props.plugin?.repo;
+ if (!repoUrl) return null;
+
+ try {
+ // 解析 GitHub URL,提取 owner
+ const url = new URL(repoUrl);
+ if (url.hostname.toLowerCase() !== 'github.com') return null;
+
+ const pathParts = url.pathname.split('/').filter(p => p);
+ if (pathParts.length < 1) return null;
+
+ const owner = pathParts[0];
+ return `https://github.com/${owner}`;
+ } catch {
+ return null;
+ }
+});
+
@@ -98,6 +122,22 @@ const handleInstall = (plugin) => {
>
{{ plugin.author }}
+
+ {{ plugin.author }}
+
{
+
+
+ {{ tm("buttons.viewDocs") }}
+
{
return '';
});
+// 从 repo URL 提取作者主页链接
+const authorHomepageUrl = computed(() => {
+ const repoUrl = props.extension?.repo;
+ if (!repoUrl) return null;
+
+ try {
+ // 解析 GitHub URL,提取 owner
+ const url = new URL(repoUrl);
+ if (url.hostname.toLowerCase() !== 'github.com') return null;
+
+ const pathParts = url.pathname.split('/').filter(p => p);
+ if (pathParts.length < 1) return null;
+
+ const owner = pathParts[0];
+ return `https://github.com/${owner}`;
+ } catch {
+ return null;
+ }
+});
+
const logoLoadFailed = ref(false);
const logoSrc = computed(() => {
@@ -356,7 +376,19 @@ const viewChangelog = () => {
{{ tag === "danger" ? tm("tags.danger") : tag }}
-
+
+
+
+ {{ authorDisplay }}
+
+
+
{{ authorDisplay }}
@@ -462,6 +494,10 @@ const viewChangelog = () => {
}}
+
+
diff --git a/dashboard/src/components/shared/ReadmeDialog.vue b/dashboard/src/components/shared/ReadmeDialog.vue
index f7c2d2faf6..fbf2d49da7 100644
--- a/dashboard/src/components/shared/ReadmeDialog.vue
+++ b/dashboard/src/components/shared/ReadmeDialog.vue
@@ -239,7 +239,7 @@ const requiresPluginName = computed(
);
async function fetchContent() {
- if (requiresPluginName.value && !props.pluginName) return;
+ if (requiresPluginName.value && !props.pluginName && !props.repoUrl) return;
const requestId = ++lastRequestId.value;
loading.value = true;
content.value = null;
@@ -250,6 +250,10 @@ async function fetchContent() {
let params;
if (requiresPluginName.value) {
params = { name: props.pluginName };
+ // 如果提供了 repoUrl,优先使用远程获取
+ if (props.repoUrl) {
+ params.repo = props.repoUrl;
+ }
} else if (props.mode === "first-notice") {
params = { locale: locale.value };
}
@@ -270,10 +274,10 @@ async function fetchContent() {
}
watch(
- [() => props.show, () => props.pluginName, () => props.mode],
+ [() => props.show, () => props.pluginName, () => props.mode, () => props.repoUrl],
([show, name]) => {
if (!show) return;
- if (requiresPluginName.value && !name) return;
+ if (requiresPluginName.value && !name && !props.repoUrl) return;
fetchContent();
},
{ immediate: true },
diff --git a/dashboard/src/i18n/locales/en-US/features/extension.json b/dashboard/src/i18n/locales/en-US/features/extension.json
index 233f28dd5a..d8fe6d1288 100644
--- a/dashboard/src/i18n/locales/en-US/features/extension.json
+++ b/dashboard/src/i18n/locales/en-US/features/extension.json
@@ -43,6 +43,7 @@
"viewInfo": "Handlers",
"viewDocs": "Documentation",
"viewRepo": "Repository",
+ "viewChangelog": "View Changelog",
"close": "Close",
"save": "Save",
"saveAndClose": "Save and Close",
diff --git a/dashboard/src/i18n/locales/ru-RU/features/extension.json b/dashboard/src/i18n/locales/ru-RU/features/extension.json
index b51d0cf783..5dd5e593df 100644
--- a/dashboard/src/i18n/locales/ru-RU/features/extension.json
+++ b/dashboard/src/i18n/locales/ru-RU/features/extension.json
@@ -1,4 +1,4 @@
-{
+{
"title": "Плагины",
"subtitle": "Управление и настройка расширений системы",
"tabs": {
@@ -43,6 +43,7 @@
"viewInfo": "Детали",
"viewDocs": "Документация",
"viewRepo": "Репозиторий",
+ "viewChangelog": "Смотреть журнал изменений",
"close": "Закрыть",
"save": "Сохранить",
"saveAndClose": "Сохранить и закрыть",
diff --git a/dashboard/src/i18n/locales/zh-CN/features/extension.json b/dashboard/src/i18n/locales/zh-CN/features/extension.json
index 04eaa8bfad..316dbbcf14 100644
--- a/dashboard/src/i18n/locales/zh-CN/features/extension.json
+++ b/dashboard/src/i18n/locales/zh-CN/features/extension.json
@@ -44,6 +44,7 @@
"viewInfo": "行为",
"viewDocs": "文档",
"viewRepo": "仓库",
+ "viewChangelog": "查看更新日志",
"close": "关闭",
"save": "保存",
"saveAndClose": "保存并关闭",
diff --git a/dashboard/src/views/extension/InstalledPluginsTab.vue b/dashboard/src/views/extension/InstalledPluginsTab.vue
index f7cbda8e5b..c9b043e78f 100644
--- a/dashboard/src/views/extension/InstalledPluginsTab.vue
+++ b/dashboard/src/views/extension/InstalledPluginsTab.vue
@@ -199,6 +199,25 @@ const handlePinnedImgError = (e) => {
e.target.src = defaultPluginIcon;
};
+// 从 repo URL 提取作者主页链接
+const getAuthorHomepageUrl = (repoUrl) => {
+ if (!repoUrl) return null;
+
+ try {
+ // 解析 GitHub URL,提取 owner
+ const url = new URL(repoUrl);
+ if (url.hostname.toLowerCase() !== 'github.com') return null;
+
+ const pathParts = url.pathname.split('/').filter(p => p);
+ if (pathParts.length < 1) return null;
+
+ const owner = pathParts[0];
+ return `https://github.com/${owner}`;
+ } catch {
+ return null;
+ }
+};
+
// --- 拖拽功能实现 ---
const draggedIndex = ref(-1);
let lastSwapTime = 0;
@@ -603,7 +622,17 @@ const pinnedPlugins = computed(() => {
- {{ item.author }}
+
+ {{ item.author }}
+
+ {{ item.author }}
@@ -705,6 +734,14 @@ const pinnedPlugins = computed(() => {
{{ tm("buttons.update") }}
+
+