From 8d387d0ca1dc1b73e64db609d5d55e3e0d60c9df Mon Sep 17 00:00:00 2001 From: LoveLosita <2810873701@qq.com> Date: Tue, 12 May 2026 12:04:07 +0800 Subject: [PATCH] fix(webui): disable update checks and deploy local dashboard assets --- .gitea/workflows/release-offline.yml | 13 + .gitignore | 2 +- .../src/routes/plugins/InstallDialog.tsx | 144 +++ dashboard/src/routes/plugins/InstalledTab.tsx | 85 ++ .../src/routes/plugins/MarketplaceTab.tsx | 87 ++ dashboard/src/routes/plugins/PluginCard.tsx | 260 +++++ dashboard/src/routes/plugins/index.tsx | 939 ++++++++++++++++++ dashboard/src/routes/plugins/types.ts | 18 + .../server-maibot/docker-compose.server.yml | 1 + 9 files changed, 1548 insertions(+), 1 deletion(-) create mode 100644 dashboard/src/routes/plugins/InstallDialog.tsx create mode 100644 dashboard/src/routes/plugins/InstalledTab.tsx create mode 100644 dashboard/src/routes/plugins/MarketplaceTab.tsx create mode 100644 dashboard/src/routes/plugins/PluginCard.tsx create mode 100644 dashboard/src/routes/plugins/index.tsx create mode 100644 dashboard/src/routes/plugins/types.ts diff --git a/.gitea/workflows/release-offline.yml b/.gitea/workflows/release-offline.yml index a22a8c0f..bf023786 100644 --- a/.gitea/workflows/release-offline.yml +++ b/.gitea/workflows/release-offline.yml @@ -51,6 +51,17 @@ jobs: app_tag="$(git -C "$worktree" rev-parse --short=12 HEAD)" printf 'APP_TAG=%s\n' "$app_tag" >> "$GITHUB_ENV" + - name: Build dashboard assets + shell: bash + run: | + set -euo pipefail + + cd "$MAIBOT_WORKTREE/dashboard" + npm ci + npm run build + + test -f dist/index.html + - name: Stage release directory shell: bash run: | @@ -69,6 +80,8 @@ jobs: rm -rf "$release_dir" mkdir -p "$release_dir" git -C "$MAIBOT_WORKTREE" archive HEAD | tar -x -C "$release_dir" + mkdir -p "$release_dir/dashboard" + cp -a "$MAIBOT_WORKTREE/dashboard/dist" "$release_dir/dashboard/dist" printf 'RELEASE_DIR=%s\n' "$release_dir" >> "$GITHUB_ENV" diff --git a/.gitignore b/.gitignore index 67026a9d..17201a56 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ venv/ # Runtime state data/ logs/ -plugins/ +/plugins/ docker-config/ config/*.toml config/*.bak* diff --git a/dashboard/src/routes/plugins/InstallDialog.tsx b/dashboard/src/routes/plugins/InstallDialog.tsx new file mode 100644 index 00000000..7c12a8b9 --- /dev/null +++ b/dashboard/src/routes/plugins/InstallDialog.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { Download } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' + +import type { PluginInfo } from './types' + +interface InstallDialogProps { + open: boolean + plugin: PluginInfo | null + onOpenChange: (open: boolean) => void + onInstall: (branch: string) => void +} + +export function InstallDialog({ open, plugin, onOpenChange, onInstall }: InstallDialogProps) { + const [selectedBranch, setSelectedBranch] = useState('main') + const [customBranch, setCustomBranch] = useState('') + const [branchInputMode, setBranchInputMode] = useState<'preset' | 'custom'>('preset') + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) + + const handleInstall = () => { + const branch = branchInputMode === 'custom' ? customBranch : selectedBranch + + if (!branch || branch.trim() === '') { + return + } + + onInstall(branch) + onOpenChange(false) + } + + return ( + + + + 安装插件 + 安装 {plugin?.manifest.name} + + +
+ {/* 基本信息 */} +
+

版本: {plugin?.manifest.version}

+

+ 作者:{' '} + {typeof plugin?.manifest.author === 'string' + ? plugin.manifest.author + : plugin?.manifest.author?.name} +

+
+ + {/* 高级选项开关 */} +
+ setShowAdvancedOptions(checked as boolean)} + /> + +
+ + {/* 高级选项内容 */} + {showAdvancedOptions && ( +
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- section heading above Tabs, not a form label */} + + + setBranchInputMode(value as 'preset' | 'custom')} + > + + + 预设分支 + + + 自定义分支 + + + + {/* 预设分支选择 */} + {branchInputMode === 'preset' && ( +
+ +
+ )} + + {/* 自定义分支输入 */} + {branchInputMode === 'custom' && ( +
+ setCustomBranch(event.target.value)} + /> +

输入 Git 分支名称、标签或提交哈希

+
+ )} +
+
+
+ )} + + {!showAdvancedOptions && ( +

将从默认分支 (main) 安装插件

+ )} +
+ + + + + +
+
+ ) +} diff --git a/dashboard/src/routes/plugins/InstalledTab.tsx b/dashboard/src/routes/plugins/InstalledTab.tsx new file mode 100644 index 00000000..2693d535 --- /dev/null +++ b/dashboard/src/routes/plugins/InstalledTab.tsx @@ -0,0 +1,85 @@ +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' +import { PluginCard } from './PluginCard' + +interface InstalledTabProps { + plugins: PluginInfo[] + searchQuery: string + categoryFilter: string + showCompatibleOnly: boolean + gitStatus: GitStatus | null + maimaiVersion: MaimaiVersion | null + pluginStats: Record + loadProgress: PluginLoadProgress | null + onInstall: (plugin: PluginInfo) => void + onUpdate: (plugin: PluginInfo) => void + onUninstall: (plugin: PluginInfo) => void + checkPluginCompatibility: (plugin: PluginInfo) => boolean + needsUpdate: (plugin: PluginInfo) => boolean + getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null + getIncompatibleReason: (plugin: PluginInfo) => string | null +} + +export function InstalledTab({ + plugins, + searchQuery, + categoryFilter, + showCompatibleOnly, + gitStatus, + maimaiVersion, + pluginStats, + loadProgress, + onInstall, + onUpdate, + onUninstall, + checkPluginCompatibility, + needsUpdate, + getStatusBadge, + getIncompatibleReason, +}: InstalledTabProps) { + const filteredPlugins = plugins.filter((plugin) => { + if (!plugin.manifest) { + return false + } + + if (!plugin.installed) { + return false + } + + const matchesSearch = + searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && + plugin.manifest.keywords.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase()))) + + const matchesCategory = + categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + + const matchesCompatibility = !showCompatibleOnly || !maimaiVersion || checkPluginCompatibility(plugin) + + return matchesSearch && matchesCategory && matchesCompatibility + }) + + return ( +
+ {filteredPlugins.map((plugin) => ( + + ))} +
+ ) +} diff --git a/dashboard/src/routes/plugins/MarketplaceTab.tsx b/dashboard/src/routes/plugins/MarketplaceTab.tsx new file mode 100644 index 00000000..39ad17c7 --- /dev/null +++ b/dashboard/src/routes/plugins/MarketplaceTab.tsx @@ -0,0 +1,87 @@ +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' +import { PluginCard } from './PluginCard' + +interface MarketplaceTabProps { + plugins: PluginInfo[] + searchQuery: string + categoryFilter: string + showCompatibleOnly: boolean + gitStatus: GitStatus | null + maimaiVersion: MaimaiVersion | null + pluginStats: Record + loadProgress: PluginLoadProgress | null + onInstall: (plugin: PluginInfo) => void + onUpdate: (plugin: PluginInfo) => void + onUninstall: (plugin: PluginInfo) => void + checkPluginCompatibility: (plugin: PluginInfo) => boolean + needsUpdate: (plugin: PluginInfo) => boolean + getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null + getIncompatibleReason: (plugin: PluginInfo) => string | null +} + +export function MarketplaceTab({ + plugins, + searchQuery, + categoryFilter, + showCompatibleOnly, + gitStatus, + maimaiVersion, + pluginStats, + loadProgress, + onInstall, + onUpdate, + onUninstall, + checkPluginCompatibility, + needsUpdate, + getStatusBadge, + getIncompatibleReason, +}: MarketplaceTabProps) { + const filteredPlugins = plugins.filter((plugin) => { + if (!plugin.manifest) { + console.warn('[过滤] 跳过无 manifest 的插件:', plugin.id) + return false + } + + // 全部插件只展示 plugin-repo 中存在的市场插件,本地独有插件只在“已安装”显示。 + if (plugin.source === 'local') { + return false + } + + const matchesSearch = + searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && + plugin.manifest.keywords.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase()))) + + const matchesCategory = + categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + + const matchesCompatibility = !showCompatibleOnly || !maimaiVersion || checkPluginCompatibility(plugin) + + return matchesSearch && matchesCategory && matchesCompatibility + }) + + return ( +
+ {filteredPlugins.map((plugin) => ( + + ))} +
+ ) +} diff --git a/dashboard/src/routes/plugins/PluginCard.tsx b/dashboard/src/routes/plugins/PluginCard.tsx new file mode 100644 index 00000000..9fb236b7 --- /dev/null +++ b/dashboard/src/routes/plugins/PluginCard.tsx @@ -0,0 +1,260 @@ +import { useNavigate } from '@tanstack/react-router' +import { AlertCircle, CheckCircle2, Download, Loader2, RefreshCw, Star, Trash2 } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Progress } from '@/components/ui/progress' + +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress, PluginStatsData } from './types' +import { CATEGORY_NAMES } from './types' + +interface PluginCardProps { + plugin: PluginInfo + gitStatus: GitStatus | null + maimaiVersion: MaimaiVersion | null + pluginStats: Record + loadProgress: PluginLoadProgress | null + onInstall: (plugin: PluginInfo) => void + onUpdate: (plugin: PluginInfo) => void + onUninstall: (plugin: PluginInfo) => void + checkPluginCompatibility: (plugin: PluginInfo) => boolean + needsUpdate: (plugin: PluginInfo) => boolean + getStatusBadge: (plugin: PluginInfo) => React.JSX.Element | null + getIncompatibleReason: (plugin: PluginInfo) => string | null +} + +export function PluginCard({ + plugin, + gitStatus, + maimaiVersion, + pluginStats, + loadProgress, + onInstall, + onUpdate, + onUninstall, + checkPluginCompatibility, + needsUpdate, + getStatusBadge, + getIncompatibleReason, +}: PluginCardProps) { + const navigate = useNavigate() + + return ( + + +
+ {plugin.manifest?.name || plugin.id} +
+ {plugin.manifest?.categories && plugin.manifest.categories[0] && ( + + {CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]} + + )} + {getStatusBadge(plugin)} +
+
+ + {plugin.manifest?.description || '无描述'} + +
+ +
+ {/* 统计信息 */} +
+
+ + {(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()} +
+
+ + {(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)} +
+
+ {/* 标签 */} +
+ {plugin.manifest?.keywords && + plugin.manifest.keywords.slice(0, 3).map((keyword) => ( + + {keyword} + + ))} + {plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && ( + + +{plugin.manifest.keywords.length - 3} + + )} +
+ {/* 版本和作者 */} +
+
+ v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'} +
+ {/* 支持版本 */} + {plugin.manifest?.host_application && ( +
+ 支持: + + {plugin.manifest.host_application.min_version} + {plugin.manifest.host_application.max_version + ? ` - ${plugin.manifest.host_application.max_version}` + : ' - 最新版本'} + +
+ )} +
+
+
+ +
+ + {plugin.installed ? ( + needsUpdate(plugin) ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ {/* 安装/卸载/更新进度显示 - 在卡片下方 */} + {loadProgress && + (loadProgress.stage === 'loading' || + loadProgress.stage === 'success' || + loadProgress.stage === 'error') && + loadProgress.operation !== 'fetch' && + loadProgress.plugin_id === plugin.id && ( +
+
+
+
+ {loadProgress.stage === 'loading' ? ( + + ) : loadProgress.stage === 'success' ? ( + + ) : ( + + )} + + {loadProgress.stage === 'loading' ? ( + <> + {loadProgress.operation === 'install' && '正在安装'} + {loadProgress.operation === 'uninstall' && '正在卸载'} + {loadProgress.operation === 'update' && '正在更新'} + + ) : loadProgress.stage === 'success' ? ( + <> + {loadProgress.operation === 'install' && '安装完成'} + {loadProgress.operation === 'uninstall' && '卸载完成'} + {loadProgress.operation === 'update' && '更新完成'} + + ) : ( + <> + {loadProgress.operation === 'install' && '安装失败'} + {loadProgress.operation === 'uninstall' && '卸载失败'} + {loadProgress.operation === 'update' && '更新失败'} + + )} + +
+ {loadProgress.stage !== 'error' && ( + + {loadProgress.progress}% + + )} +
+ {loadProgress.stage !== 'error' && ( + div]:bg-green-500' : ''}`} + /> + )} +
+ {loadProgress.stage === 'error' + ? (loadProgress.error || loadProgress.message || '操作失败') + : loadProgress.message} +
+
+
+ )} +
+ ) +} diff --git a/dashboard/src/routes/plugins/index.tsx b/dashboard/src/routes/plugins/index.tsx new file mode 100644 index 00000000..ac92ca27 --- /dev/null +++ b/dashboard/src/routes/plugins/index.tsx @@ -0,0 +1,939 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from '@tanstack/react-router' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Checkbox } from '@/components/ui/checkbox' +import { Input } from '@/components/ui/input' +import { Progress } from '@/components/ui/progress' +import { ScrollArea } from '@/components/ui/scroll-area' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { AlertCircle, AlertTriangle, CheckCircle2, Info, Loader2, RotateCw, Search, Settings2 } from 'lucide-react' + +import { RestartOverlay } from '@/components/restart-overlay' +import { useToast } from '@/hooks/use-toast' +import { RestartProvider, useRestart } from '@/lib/restart-context' +import { + checkGitStatus, + checkPluginInstalled, + connectPluginProgressWebSocket, + fetchPluginList, + getInstalledPluginVersion, + getInstalledPlugins, + getMaimaiVersion, + installPlugin, + isPluginCompatible, + uninstallPlugin, + updatePlugin, + type InstalledPlugin, +} from '@/lib/plugin-api' +import { getPluginStats, recordPluginDownload, type PluginStatsData } from '@/lib/plugin-stats' + +import { InstallDialog } from './InstallDialog' +import { InstalledTab } from './InstalledTab' +import { MarketplaceTab } from './MarketplaceTab' +import { PluginCard } from './PluginCard' +import type { GitStatus, MaimaiVersion, PluginInfo, PluginLoadProgress } from './types' + +// 主导出组件:包装 RestartProvider +export function PluginsPage() { + return ( + + + + ) +} + +// 内部组件:实际内容 +function PluginsPageContent() { + const navigate = useNavigate() + const { triggerRestart, isRestarting } = useRestart() + const [restartNoticeVisible, setRestartNoticeVisible] = useState( + () => localStorage.getItem('plugins-restart-notice-dismissed') !== 'true' + ) + const [searchQuery, setSearchQuery] = useState('') + const [categoryFilter, setCategoryFilter] = useState('all') + const [activeTab, setActiveTab] = useState('all') // all | installed | updates + const [showCompatibleOnly, setShowCompatibleOnly] = useState(true) // 默认只显示兼容的 + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [gitStatus, setGitStatus] = useState(null) + const [loadProgress, setLoadProgress] = useState(null) + const [maimaiVersion, setMaimaiVersion] = useState(null) + const [, setInstalledPlugins] = useState([]) + const [pluginStats, setPluginStats] = useState>({}) + + // 安装对话框状态 + const [installDialogOpen, setInstallDialogOpen] = useState(false) + const [installingPlugin, setInstallingPlugin] = useState(null) + + const { toast } = useToast() + + const dismissRestartNotice = () => { + localStorage.setItem('plugins-restart-notice-dismissed', 'true') + setRestartNoticeVisible(false) + } + + // 加载插件统计数据 + const loadPluginStats = async (pluginList: PluginInfo[]) => { + const statsPromises = pluginList.map(async (plugin) => { + try { + const stats = await getPluginStats(plugin.id) + return { id: plugin.id, stats } + } catch (loadStatsError) { + console.warn(`Failed to load stats for ${plugin.id}:`, loadStatsError) + return { id: plugin.id, stats: null } + } + }) + + const results = await Promise.all(statsPromises) + const statsMap: Record = {} + + results.forEach(({ id, stats }) => { + if (stats) { + statsMap[id] = stats + } + }) + + setPluginStats(statsMap) + } + + // 统一管理 WebSocket 和数据加载 + useEffect(() => { + let unsubscribeProgress: (() => Promise) | null = null + let isUnmounted = false + + const init = async () => { + // 1. 先连接 WebSocket(异步获取 token) + unsubscribeProgress = await connectPluginProgressWebSocket( + (progress) => { + if (isUnmounted) return + + setLoadProgress(progress) + + // 如果加载完成,清除进度 + if (progress.stage === 'success') { + setTimeout(() => { + if (!isUnmounted) { + setLoadProgress(null) + } + }, 2000) + } else if (progress.stage === 'error') { + setLoading(false) + setError(progress.error || '加载失败') + } + }, + (websocketError) => { + console.error('WebSocket error:', websocketError) + if (!isUnmounted) { + toast({ + title: 'WebSocket 连接失败', + description: '无法实时显示加载进度', + variant: 'destructive', + }) + } + } + ) + + // 2. 检查 Git 状态 + if (!isUnmounted) { + const statusResult = await checkGitStatus() + if (!statusResult.success) { + toast({ + title: 'Git 状态检查失败', + description: statusResult.error, + variant: 'destructive', + }) + setGitStatus({ installed: false, error: statusResult.error }) + } else { + setGitStatus(statusResult.data) + + if (!statusResult.data.installed) { + toast({ + title: 'Git 未安装', + description: statusResult.data.error || '请先安装 Git 才能使用插件安装功能', + variant: 'destructive', + }) + } + } + } + + // 3. 获取麦麦版本 + if (!isUnmounted) { + const versionResult = await getMaimaiVersion() + if (!versionResult.success) { + toast({ + title: '版本获取失败', + description: versionResult.error, + variant: 'destructive', + }) + } else { + setMaimaiVersion(versionResult.data) + } + } + + // 4. 加载插件列表(包含已安装信息) + if (!isUnmounted) { + try { + setLoading(true) + setError(null) + const apiResult = await fetchPluginList() + if (!apiResult.success) { + if (!isUnmounted) { + setError(apiResult.error) + toast({ + title: '加载失败', + description: apiResult.error, + variant: 'destructive', + }) + } + return + } + const data = apiResult.data + + if (!isUnmounted) { + // 获取已安装插件列表 + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data + setInstalledPlugins(installed) + + // 将已安装信息合并到插件数据中 + const mergedData = data.map((plugin) => { + const isInstalled = checkPluginInstalled(plugin.id, installed) + const installedVersion = getInstalledPluginVersion(plugin.id, installed) + + return { + ...plugin, + installed: isInstalled, + installed_version: installedVersion, + } + }) + + // 添加本地安装但不在市场的插件 + for (const installedPlugin of installed) { + const existsInMarket = mergedData.some((plugin) => plugin.id === installedPlugin.id) + if (!existsInMarket && installedPlugin.manifest) { + const urls = installedPlugin.manifest.urls as PluginInfo['manifest']['urls'] | undefined + mergedData.push({ + id: installedPlugin.id, + manifest: { + manifest_version: installedPlugin.manifest.manifest_version || 1, + id: installedPlugin.manifest.id || installedPlugin.id, + name: installedPlugin.manifest.name, + version: installedPlugin.manifest.version, + description: installedPlugin.manifest.description || '', + author: installedPlugin.manifest.author, + license: installedPlugin.manifest.license || 'Unknown', + host_application: installedPlugin.manifest.host_application, + homepage_url: installedPlugin.manifest.homepage_url || urls?.homepage, + repository_url: installedPlugin.manifest.repository_url || urls?.repository, + urls, + keywords: installedPlugin.manifest.keywords || [], + categories: installedPlugin.manifest.categories || [], + default_locale: (installedPlugin.manifest.default_locale as string) || 'zh-CN', + locales_path: installedPlugin.manifest.locales_path as string | undefined, + }, + downloads: 0, + rating: 0, + review_count: 0, + installed: true, + installed_version: installedPlugin.manifest.version, + source: 'local', + published_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + } + } + + setPlugins(mergedData) + loadPluginStats(mergedData) + } + } finally { + if (!isUnmounted) { + setLoading(false) + } + } + } + } + + void init() + + return () => { + isUnmounted = true + if (unsubscribeProgress) { + void unsubscribeProgress() + } + } + }, [toast]) + + // 获取插件状态徽章 + const getStatusBadge = (plugin: PluginInfo) => { + // 优先显示兼容性状态(已安装但不兼容也需要提示,避免用户误以为可继续更新) + if (maimaiVersion && !checkPluginCompatibility(plugin)) { + return ( + + + 不兼容 + + ) + } + + if (plugin.installed) { + const installedVer = plugin.installed_version?.trim() + const marketVer = plugin.manifest.version?.trim() + + if (installedVer !== marketVer) { + const installedParts = installedVer?.split('.').map(Number) || [0, 0, 0] + const marketParts = marketVer?.split('.').map(Number) || [0, 0, 0] + + for (let i = 0; i < 3; i++) { + if ((marketParts[i] || 0) > (installedParts[i] || 0)) { + return ( + + + 可更新 + + ) + } + if ((marketParts[i] || 0) < (installedParts[i] || 0)) { + break + } + } + } + + return ( + + + 已安装 + + ) + } + + return null + } + + // 检查插件兼容性 + // 规则: + // 1. manifest_version === 1 的插件在麦麦 >= 1.0.0 时一律视为不兼容(旧 manifest 已不再被宿主接受); + // 2. 否则若声明了 host_application 范围,则按版本范围判定。 + const checkPluginCompatibility = (plugin: PluginInfo): boolean => { + if (!maimaiVersion) return true + + const manifestVersion = plugin.manifest?.manifest_version ?? 1 + if (manifestVersion <= 1 && maimaiVersion.version_major >= 1) { + return false + } + + if (!plugin.manifest?.host_application) return true + + return isPluginCompatible( + plugin.manifest.host_application.min_version, + plugin.manifest.host_application.max_version, + maimaiVersion + ) + } + + // 不兼容原因(用于 UI 提示) + const getIncompatibleReason = (plugin: PluginInfo): string | null => { + if (!maimaiVersion) return null + + const manifestVersion = plugin.manifest?.manifest_version ?? 1 + if (manifestVersion <= 1 && maimaiVersion.version_major >= 1) { + return `该插件使用旧版 manifest (v${manifestVersion}),已不被麦麦 ${maimaiVersion.version} 支持` + } + + if ( + plugin.manifest?.host_application && + !isPluginCompatible( + plugin.manifest.host_application.min_version, + plugin.manifest.host_application.max_version, + maimaiVersion + ) + ) { + const min = plugin.manifest.host_application.min_version || '未知' + const max = plugin.manifest.host_application.max_version + const range = max ? `${min} - ${max}` : `${min}+` + return `不兼容当前版本 (需要 ${range},当前 ${maimaiVersion.version})` + } + + return null + } + + // 检查是否需要更新(市场版本比已安装版本新) + const needsUpdate = (plugin: PluginInfo): boolean => { + if (!plugin.installed || !plugin.installed_version || !plugin.manifest?.version) { + return false + } + if (!checkPluginCompatibility(plugin)) { + return false + } + + const installedVer = plugin.installed_version.trim() + const marketVer = plugin.manifest.version.trim() + + if (installedVer === marketVer) return false + + const installedParts = installedVer.split('.').map(Number) + const marketParts = marketVer.split('.').map(Number) + + for (let i = 0; i < 3; i++) { + if ((marketParts[i] || 0) > (installedParts[i] || 0)) { + return true + } + if ((marketParts[i] || 0) < (installedParts[i] || 0)) { + return false + } + } + + return false + } + + // 打开安装对话框 + const openInstallDialog = (plugin: PluginInfo) => { + if (!gitStatus?.installed) { + toast({ + title: '无法安装', + description: 'Git 未安装', + variant: 'destructive', + }) + return + } + + if (maimaiVersion && !checkPluginCompatibility(plugin)) { + toast({ + title: '无法安装', + description: getIncompatibleReason(plugin) ?? '插件与当前麦麦版本不兼容', + variant: 'destructive', + }) + return + } + + setInstallingPlugin(plugin) + setInstallDialogOpen(true) + } + + // 安装插件处理 + const handleInstall = async (branch: string) => { + if (!installingPlugin) return + + if (!branch || branch.trim() === '') { + toast({ + title: '分支名称不能为空', + variant: 'destructive', + }) + return + } + + try { + setInstallDialogOpen(false) + + const installResult = await installPlugin( + installingPlugin.id, + installingPlugin.manifest.repository_url || installingPlugin.manifest.urls?.repository || '', + branch + ) + + if (!installResult.success) { + toast({ + title: '安装失败', + description: installResult.error, + variant: 'destructive', + }) + return + } + + recordPluginDownload(installingPlugin.id).catch((recordError) => { + console.warn('Failed to record download:', recordError) + }) + + toast({ + title: '安装成功', + description: `${installingPlugin.manifest.name} 已成功安装`, + }) + + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data + setInstalledPlugins(installed) + + setPlugins((prevPlugins) => + prevPlugins.map((plugin) => { + if (plugin.id === installingPlugin.id) { + const isInstalled = checkPluginInstalled(plugin.id, installed) + const installedVersion = getInstalledPluginVersion(plugin.id, installed) + + return { + ...plugin, + installed: isInstalled, + installed_version: installedVersion, + } + } + return plugin + }) + ) + } catch (installError) { + toast({ + title: '安装失败', + description: installError instanceof Error ? installError.message : '未知错误', + variant: 'destructive', + }) + } finally { + setInstallingPlugin(null) + } + } + + // 卸载插件处理 + const handleUninstall = async (plugin: PluginInfo) => { + try { + const uninstallResult = await uninstallPlugin(plugin.id) + + if (!uninstallResult.success) { + toast({ + title: '卸载失败', + description: uninstallResult.error, + variant: 'destructive', + }) + return + } + + toast({ + title: '卸载成功', + description: `${plugin.manifest.name} 已成功卸载`, + }) + + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data + setInstalledPlugins(installed) + + setPlugins((prevPlugins) => + prevPlugins.map((item) => { + if (item.id === plugin.id) { + const isInstalled = checkPluginInstalled(item.id, installed) + const installedVersion = getInstalledPluginVersion(item.id, installed) + + return { + ...item, + installed: isInstalled, + installed_version: installedVersion, + } + } + return item + }) + ) + } catch (uninstallError) { + toast({ + title: '卸载失败', + description: uninstallError instanceof Error ? uninstallError.message : '未知错误', + variant: 'destructive', + }) + } + } + + // 更新插件处理 + const handleUpdate = async (plugin: PluginInfo) => { + if (!gitStatus?.installed) { + toast({ + title: '无法更新', + description: 'Git 未安装', + variant: 'destructive', + }) + return + } + + if (maimaiVersion && !checkPluginCompatibility(plugin)) { + toast({ + title: '无法更新', + description: getIncompatibleReason(plugin) ?? '插件与当前麦麦版本不兼容', + variant: 'destructive', + }) + return + } + + try { + const updateResult = await updatePlugin( + plugin.id, + plugin.manifest.repository_url || plugin.manifest.urls?.repository || '', + 'main' + ) + + if (!updateResult.success) { + toast({ + title: '更新失败', + description: updateResult.error, + variant: 'destructive', + }) + return + } + + toast({ + title: '更新成功', + description: `${plugin.manifest.name} 已从 ${updateResult.data.old_version} 更新到 ${updateResult.data.new_version}`, + }) + + const installedResult = await getInstalledPlugins() + if (!installedResult.success) { + toast({ + title: '获取已安装插件失败', + description: installedResult.error, + variant: 'destructive', + }) + return + } + const installed = installedResult.data + setInstalledPlugins(installed) + + setPlugins((prevPlugins) => + prevPlugins.map((item) => { + if (item.id === plugin.id) { + const isInstalled = checkPluginInstalled(item.id, installed) + const installedVersion = getInstalledPluginVersion(item.id, installed) + + return { + ...item, + installed: isInstalled, + installed_version: installedVersion, + } + } + return item + }) + ) + } catch (updateError) { + toast({ + title: '更新失败', + description: updateError instanceof Error ? updateError.message : '未知错误', + variant: 'destructive', + }) + } + } + + // 过滤插件用于标签页统计 + const getFilteredPluginCount = (tab: 'all' | 'installed' | 'updates') => { + return plugins.filter((plugin) => { + if (!plugin.manifest) return false + if (tab === 'all' && plugin.source === 'local') return false + + const matchesSearch = + searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && + plugin.manifest.keywords.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase()))) + + const matchesCategory = + categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + const matchesCompatibility = !showCompatibleOnly || !maimaiVersion || checkPluginCompatibility(plugin) + + let matchesTab = true + if (tab === 'installed') { + matchesTab = plugin.installed === true + } else if (tab === 'updates') { + matchesTab = plugin.installed === true && needsUpdate(plugin) + } + + return matchesSearch && matchesCategory && matchesCompatibility && matchesTab + }).length + } + + // 过滤插件用于可更新标签页 + const filteredUpdatablePlugins = plugins.filter((plugin) => { + if (!plugin.manifest) return false + + const matchesSearch = + searchQuery === '' || + plugin.manifest.name?.toLowerCase().includes(searchQuery.toLowerCase()) || + plugin.manifest.description?.toLowerCase().includes(searchQuery.toLowerCase()) || + (plugin.manifest.keywords && + plugin.manifest.keywords.some((keyword) => keyword.toLowerCase().includes(searchQuery.toLowerCase()))) + + const matchesCategory = + categoryFilter === 'all' || + (plugin.manifest.categories && plugin.manifest.categories.includes(categoryFilter)) + + const matchesCompatibility = !showCompatibleOnly || !maimaiVersion || checkPluginCompatibility(plugin) + + return plugin.installed && needsUpdate(plugin) && matchesSearch && matchesCategory && matchesCompatibility + }) + + return ( + +
+ {/* 标题 */} +
+
+

插件市场

+

浏览和管理麦麦的插件

+
+
+ + +
+
+ + {/* 安装提示 */} + {restartNoticeVisible && ( + + +
+
+ +

+ 安装、卸载或更新插件后,部分插件需要重启麦麦才能生效 +

+
+ +
+
+
+ )} + + {/* Git 状态警告 */} + {gitStatus && !gitStatus.installed && ( + + +
+ +
+ + Git 未安装 + + + {gitStatus.error || '请先安装 Git 才能使用插件安装功能'} + +
+
+
+ +

+ 您可以从{' '} + + git-scm.com + {' '} + 下载并安装 Git。安装完成后,请重启麦麦应用。 +

+
+
+ )} + + {/* 搜索和筛选栏 */} + +
+
+ {/* 搜索框 */} +
+ + setSearchQuery(event.target.value)} + className="pl-9" + /> +
+ + {/* 分类筛选 */} + +
+ + {/* 兼容性筛选 */} +
+ setShowCompatibleOnly(checked === true)} + /> + +
+
+
+ + {/* 标签页 */} + + + 全部插件 ({getFilteredPluginCount('all')}) + 已安装 ({getFilteredPluginCount('installed')}) + 可更新 ({getFilteredPluginCount('updates')}) + + + + {/* 进度条 - 仅显示插件清单加载进度 */} + {loadProgress && loadProgress.stage === 'loading' && loadProgress.operation === 'fetch' && ( + +
+
+
+ + 加载插件列表 +
+ {loadProgress.progress}% +
+ +
{loadProgress.message}
+ {loadProgress.total_plugins > 0 && ( +
+ 已加载 {loadProgress.loaded_plugins} / {loadProgress.total_plugins} 个插件 +
+ )} +
+
+ )} + + {/* 加载错误显示 */} + {loadProgress && loadProgress.stage === 'error' && loadProgress.error && ( + + +
+ +
+ 加载失败 + {loadProgress.error} +
+
+
+
+ )} + + {/* 插件卡片网格 */} + {loading ? ( +
+ + 加载插件列表中... +
+ ) : error ? ( + +
+ +

加载失败

+

{error}

+ +
+
+ ) : activeTab === 'all' ? ( + + ) : activeTab === 'installed' ? ( + + ) : ( +
+ {filteredUpdatablePlugins.map((plugin) => ( + + ))} +
+ )} + + {/* 安装对话框 */} + + + {/* 重启遮罩层 */} + +
+
+ ) +} diff --git a/dashboard/src/routes/plugins/types.ts b/dashboard/src/routes/plugins/types.ts new file mode 100644 index 00000000..998e36b8 --- /dev/null +++ b/dashboard/src/routes/plugins/types.ts @@ -0,0 +1,18 @@ +import type { PluginInfo } from '@/types/plugin' +import type { GitStatus, MaimaiVersion, PluginLoadProgress } from '@/lib/plugin-api' +import type { PluginStatsData } from '@/lib/plugin-stats' + +// 分类名称映射 +export const CATEGORY_NAMES: Record = { + 'Group Management': '群组管理', + 'Entertainment & Interaction': '娱乐互动', + 'Utility Tools': '实用工具', + 'Content Generation': '内容生成', + Multimedia: '多媒体', + 'External Integration': '外部集成', + 'Data Analysis & Insights': '数据分析与洞察', + Other: '其他', +} + +// 导出类型 +export type { PluginInfo, GitStatus, MaimaiVersion, PluginLoadProgress, PluginStatsData } diff --git a/deploy/server-maibot/docker-compose.server.yml b/deploy/server-maibot/docker-compose.server.yml index 3c2deca8..e5b8d75b 100644 --- a/deploy/server-maibot/docker-compose.server.yml +++ b/deploy/server-maibot/docker-compose.server.yml @@ -8,6 +8,7 @@ services: - PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6 - MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1 - MAIBOT_STATISTICS_REPORT_PATH=/MaiMBot/data/maibot_statistics.html + - MAIBOT_WEBUI_USE_LOCAL_DASHBOARD=1 ports: - "18001:8001" volumes: