fix(webui): disable update checks and deploy local dashboard assets
This commit is contained in:
@@ -51,6 +51,17 @@ jobs:
|
|||||||
app_tag="$(git -C "$worktree" rev-parse --short=12 HEAD)"
|
app_tag="$(git -C "$worktree" rev-parse --short=12 HEAD)"
|
||||||
printf 'APP_TAG=%s\n' "$app_tag" >> "$GITHUB_ENV"
|
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
|
- name: Stage release directory
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
@@ -69,6 +80,8 @@ jobs:
|
|||||||
rm -rf "$release_dir"
|
rm -rf "$release_dir"
|
||||||
mkdir -p "$release_dir"
|
mkdir -p "$release_dir"
|
||||||
git -C "$MAIBOT_WORKTREE" archive HEAD | tar -x -C "$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"
|
printf 'RELEASE_DIR=%s\n' "$release_dir" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,7 +11,7 @@ venv/
|
|||||||
# Runtime state
|
# Runtime state
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
plugins/
|
/plugins/
|
||||||
docker-config/
|
docker-config/
|
||||||
config/*.toml
|
config/*.toml
|
||||||
config/*.bak*
|
config/*.bak*
|
||||||
|
|||||||
144
dashboard/src/routes/plugins/InstallDialog.tsx
Normal file
144
dashboard/src/routes/plugins/InstallDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>安装插件</DialogTitle>
|
||||||
|
<DialogDescription>安装 {plugin?.manifest.name}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">版本: {plugin?.manifest.version}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
作者:{' '}
|
||||||
|
{typeof plugin?.manifest.author === 'string'
|
||||||
|
? plugin.manifest.author
|
||||||
|
: plugin?.manifest.author?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 高级选项开关 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="advanced-options"
|
||||||
|
checked={showAdvancedOptions}
|
||||||
|
onCheckedChange={(checked) => setShowAdvancedOptions(checked as boolean)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="advanced-options"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
高级选项
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 高级选项内容 */}
|
||||||
|
{showAdvancedOptions && (
|
||||||
|
<div className="space-y-4 p-4 border rounded-lg">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- section heading above Tabs, not a form label */}
|
||||||
|
<label className="text-sm font-medium">分支选择</label>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={branchInputMode}
|
||||||
|
onValueChange={(value) => setBranchInputMode(value as 'preset' | 'custom')}
|
||||||
|
>
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="preset" className="text-xs">
|
||||||
|
预设分支
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="custom" className="text-xs">
|
||||||
|
自定义分支
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 预设分支选择 */}
|
||||||
|
{branchInputMode === 'preset' && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="选择分支" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="main">main (默认)</SelectItem>
|
||||||
|
<SelectItem value="master">master</SelectItem>
|
||||||
|
<SelectItem value="dev">dev (开发版)</SelectItem>
|
||||||
|
<SelectItem value="develop">develop</SelectItem>
|
||||||
|
<SelectItem value="beta">beta (测试版)</SelectItem>
|
||||||
|
<SelectItem value="stable">stable (稳定版)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 自定义分支输入 */}
|
||||||
|
{branchInputMode === 'custom' && (
|
||||||
|
<div className="space-y-2 mt-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
placeholder="输入分支名称,例如: feature/new-feature"
|
||||||
|
value={customBranch}
|
||||||
|
onChange={(event) => setCustomBranch(event.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">输入 Git 分支名称、标签或提交哈希</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAdvancedOptions && (
|
||||||
|
<p className="text-sm text-muted-foreground">将从默认分支 (main) 安装插件</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleInstall}>
|
||||||
|
<Download className="h-4 w-4 mr-2" />
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
85
dashboard/src/routes/plugins/InstalledTab.tsx
Normal file
85
dashboard/src/routes/plugins/InstalledTab.tsx
Normal file
@@ -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<string, PluginStatsData>
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-5">
|
||||||
|
{filteredPlugins.map((plugin) => (
|
||||||
|
<PluginCard
|
||||||
|
key={plugin.id}
|
||||||
|
plugin={plugin}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
maimaiVersion={maimaiVersion}
|
||||||
|
pluginStats={pluginStats}
|
||||||
|
loadProgress={loadProgress}
|
||||||
|
onInstall={onInstall}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onUninstall={onUninstall}
|
||||||
|
checkPluginCompatibility={checkPluginCompatibility}
|
||||||
|
needsUpdate={needsUpdate}
|
||||||
|
getStatusBadge={getStatusBadge}
|
||||||
|
getIncompatibleReason={getIncompatibleReason}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
dashboard/src/routes/plugins/MarketplaceTab.tsx
Normal file
87
dashboard/src/routes/plugins/MarketplaceTab.tsx
Normal file
@@ -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<string, PluginStatsData>
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-5">
|
||||||
|
{filteredPlugins.map((plugin) => (
|
||||||
|
<PluginCard
|
||||||
|
key={plugin.id}
|
||||||
|
plugin={plugin}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
maimaiVersion={maimaiVersion}
|
||||||
|
pluginStats={pluginStats}
|
||||||
|
loadProgress={loadProgress}
|
||||||
|
onInstall={onInstall}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
onUninstall={onUninstall}
|
||||||
|
checkPluginCompatibility={checkPluginCompatibility}
|
||||||
|
needsUpdate={needsUpdate}
|
||||||
|
getStatusBadge={getStatusBadge}
|
||||||
|
getIncompatibleReason={getIncompatibleReason}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
260
dashboard/src/routes/plugins/PluginCard.tsx
Normal file
260
dashboard/src/routes/plugins/PluginCard.tsx
Normal file
@@ -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<string, PluginStatsData>
|
||||||
|
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 (
|
||||||
|
<Card key={plugin.id} className="flex flex-col hover:shadow-lg transition-shadow h-full">
|
||||||
|
<CardHeader className="p-5 pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<CardTitle className="text-lg leading-snug">{plugin.manifest?.name || plugin.id}</CardTitle>
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
{plugin.manifest?.categories && plugin.manifest.categories[0] && (
|
||||||
|
<Badge variant="secondary" className="text-xs whitespace-nowrap">
|
||||||
|
{CATEGORY_NAMES[plugin.manifest.categories[0]] || plugin.manifest.categories[0]}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{getStatusBadge(plugin)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="line-clamp-2 text-sm leading-snug">
|
||||||
|
{plugin.manifest?.description || '无描述'}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-5 pb-3">
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
<span>{(pluginStats[plugin.id]?.downloads ?? plugin.downloads ?? 0).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" />
|
||||||
|
<span>{(pluginStats[plugin.id]?.rating ?? plugin.rating ?? 0).toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 标签 */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{plugin.manifest?.keywords &&
|
||||||
|
plugin.manifest.keywords.slice(0, 3).map((keyword) => (
|
||||||
|
<Badge key={keyword} variant="outline" className="text-xs">
|
||||||
|
{keyword}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{plugin.manifest?.keywords && plugin.manifest.keywords.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
+{plugin.manifest.keywords.length - 3}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 版本和作者 */}
|
||||||
|
<div className="text-xs text-muted-foreground pt-2.5 border-t space-y-1">
|
||||||
|
<div>
|
||||||
|
v{plugin.manifest?.version || 'unknown'} · {plugin.manifest?.author?.name || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
{/* 支持版本 */}
|
||||||
|
{plugin.manifest?.host_application && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>支持:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{plugin.manifest.host_application.min_version}
|
||||||
|
{plugin.manifest.host_application.max_version
|
||||||
|
? ` - ${plugin.manifest.host_application.max_version}`
|
||||||
|
: ' - 最新版本'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="px-5 pt-2 pb-5">
|
||||||
|
<div className="flex items-center justify-end gap-2 w-full">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ to: '/plugin-detail', search: { pluginId: plugin.id } })}
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</Button>
|
||||||
|
{plugin.installed ? (
|
||||||
|
needsUpdate(plugin) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!gitStatus?.installed || (maimaiVersion !== null && !checkPluginCompatibility(plugin))}
|
||||||
|
title={
|
||||||
|
!gitStatus?.installed
|
||||||
|
? 'Git 未安装'
|
||||||
|
: maimaiVersion !== null && !checkPluginCompatibility(plugin)
|
||||||
|
? (getIncompatibleReason(plugin) ?? '插件与当前麦麦版本不兼容')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => onUpdate(plugin)}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
更新
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
disabled={!gitStatus?.installed}
|
||||||
|
title={!gitStatus?.installed ? 'Git 未安装' : undefined}
|
||||||
|
onClick={() => onUninstall(plugin)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
卸载
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
!gitStatus?.installed ||
|
||||||
|
loadProgress?.operation === 'install' ||
|
||||||
|
(maimaiVersion !== null && !checkPluginCompatibility(plugin))
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
!gitStatus?.installed
|
||||||
|
? 'Git 未安装'
|
||||||
|
: maimaiVersion !== null && !checkPluginCompatibility(plugin)
|
||||||
|
? (getIncompatibleReason(plugin) ?? '插件与当前麦麦版本不兼容')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => onInstall(plugin)}
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4 mr-1" />
|
||||||
|
{loadProgress?.operation === 'install' && loadProgress?.plugin_id === plugin.id
|
||||||
|
? '安装中...'
|
||||||
|
: '安装'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
{/* 安装/卸载/更新进度显示 - 在卡片下方 */}
|
||||||
|
{loadProgress &&
|
||||||
|
(loadProgress.stage === 'loading' ||
|
||||||
|
loadProgress.stage === 'success' ||
|
||||||
|
loadProgress.stage === 'error') &&
|
||||||
|
loadProgress.operation !== 'fetch' &&
|
||||||
|
loadProgress.plugin_id === plugin.id && (
|
||||||
|
<div className="px-5 pb-5 -mt-1">
|
||||||
|
<div
|
||||||
|
className={`space-y-2 p-3 rounded-lg border ${
|
||||||
|
loadProgress.stage === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-900'
|
||||||
|
: loadProgress.stage === 'error'
|
||||||
|
? 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-900'
|
||||||
|
: 'bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loadProgress.stage === 'loading' ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : loadProgress.stage === 'success' ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-3 w-3 text-red-600" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
loadProgress.stage === 'success'
|
||||||
|
? 'text-green-700 dark:text-green-300'
|
||||||
|
: loadProgress.stage === 'error'
|
||||||
|
? 'text-red-700 dark:text-red-300'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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' && '更新失败'}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{loadProgress.stage !== 'error' && (
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium ${
|
||||||
|
loadProgress.stage === 'success' ? 'text-green-700 dark:text-green-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loadProgress.progress}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{loadProgress.stage !== 'error' && (
|
||||||
|
<Progress
|
||||||
|
value={loadProgress.progress}
|
||||||
|
className={`h-1.5 ${loadProgress.stage === 'success' ? '[&>div]:bg-green-500' : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`text-xs ${
|
||||||
|
loadProgress.stage === 'success'
|
||||||
|
? 'text-green-600 dark:text-green-400 truncate'
|
||||||
|
: loadProgress.stage === 'error'
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-muted-foreground truncate'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{loadProgress.stage === 'error'
|
||||||
|
? (loadProgress.error || loadProgress.message || '操作失败')
|
||||||
|
: loadProgress.message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
939
dashboard/src/routes/plugins/index.tsx
Normal file
939
dashboard/src/routes/plugins/index.tsx
Normal file
@@ -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 (
|
||||||
|
<RestartProvider>
|
||||||
|
<PluginsPageContent />
|
||||||
|
</RestartProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内部组件:实际内容
|
||||||
|
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<PluginInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [gitStatus, setGitStatus] = useState<GitStatus | null>(null)
|
||||||
|
const [loadProgress, setLoadProgress] = useState<PluginLoadProgress | null>(null)
|
||||||
|
const [maimaiVersion, setMaimaiVersion] = useState<MaimaiVersion | null>(null)
|
||||||
|
const [, setInstalledPlugins] = useState<InstalledPlugin[]>([])
|
||||||
|
const [pluginStats, setPluginStats] = useState<Record<string, PluginStatsData>>({})
|
||||||
|
|
||||||
|
// 安装对话框状态
|
||||||
|
const [installDialogOpen, setInstallDialogOpen] = useState(false)
|
||||||
|
const [installingPlugin, setInstallingPlugin] = useState<PluginInfo | null>(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<string, PluginStatsData> = {}
|
||||||
|
|
||||||
|
results.forEach(({ id, stats }) => {
|
||||||
|
if (stats) {
|
||||||
|
statsMap[id] = stats
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setPluginStats(statsMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一管理 WebSocket 和数据加载
|
||||||
|
useEffect(() => {
|
||||||
|
let unsubscribeProgress: (() => Promise<void>) | 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 (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
不兼容
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Badge variant="outline" className="gap-1 text-orange-600 border-orange-600">
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
可更新
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if ((marketParts[i] || 0) < (installedParts[i] || 0)) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge variant="default" className="gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
已安装
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="space-y-6 p-4 sm:p-6">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl sm:text-3xl font-bold">插件市场</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">浏览和管理麦麦的插件</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => triggerRestart()} disabled={isRestarting}>
|
||||||
|
<RotateCw className={`h-4 w-4 mr-2 ${isRestarting ? 'animate-spin' : ''}`} />
|
||||||
|
重启麦麦
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate({ to: '/plugin-mirrors' })}>
|
||||||
|
<Settings2 className="h-4 w-4 mr-2" />
|
||||||
|
配置镜像源
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 安装提示 */}
|
||||||
|
{restartNoticeVisible && (
|
||||||
|
<Card className="border-blue-200 bg-blue-50 dark:bg-blue-950/20 dark:border-blue-900">
|
||||||
|
<CardContent className="py-3">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info className="h-4 w-4 text-blue-600 flex-shrink-0" />
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
安装、卸载或更新插件后,部分插件需要<span className="font-semibold">重启麦麦</span>才能生效
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Git 状态警告 */}
|
||||||
|
{gitStatus && !gitStatus.installed && (
|
||||||
|
<Card className="border-orange-600 bg-orange-50 dark:bg-orange-950/20">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-orange-600" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-orange-900 dark:text-orange-100">
|
||||||
|
Git 未安装
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-orange-800 dark:text-orange-200">
|
||||||
|
{gitStatus.error || '请先安装 Git 才能使用插件安装功能'}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-orange-800 dark:text-orange-200">
|
||||||
|
您可以从{' '}
|
||||||
|
<a
|
||||||
|
href="https://git-scm.com/downloads"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline font-medium"
|
||||||
|
>
|
||||||
|
git-scm.com
|
||||||
|
</a>{' '}
|
||||||
|
下载并安装 Git。安装完成后,请重启麦麦应用。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索和筛选栏 */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索插件..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分类筛选 */}
|
||||||
|
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||||
|
<SelectTrigger className="w-full sm:w-[200px]">
|
||||||
|
<SelectValue placeholder="选择分类" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部分类</SelectItem>
|
||||||
|
<SelectItem value="Group Management">群组管理</SelectItem>
|
||||||
|
<SelectItem value="Entertainment & Interaction">娱乐互动</SelectItem>
|
||||||
|
<SelectItem value="Utility Tools">实用工具</SelectItem>
|
||||||
|
<SelectItem value="Content Generation">内容生成</SelectItem>
|
||||||
|
<SelectItem value="Multimedia">多媒体</SelectItem>
|
||||||
|
<SelectItem value="External Integration">外部集成</SelectItem>
|
||||||
|
<SelectItem value="Data Analysis & Insights">数据分析与洞察</SelectItem>
|
||||||
|
<SelectItem value="Other">其他</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 兼容性筛选 */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="compatible-only"
|
||||||
|
checked={showCompatibleOnly}
|
||||||
|
onCheckedChange={(checked) => setShowCompatibleOnly(checked === true)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="compatible-only"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
只显示兼容当前版本的插件
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 标签页 */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="all">全部插件 ({getFilteredPluginCount('all')})</TabsTrigger>
|
||||||
|
<TabsTrigger value="installed">已安装 ({getFilteredPluginCount('installed')})</TabsTrigger>
|
||||||
|
<TabsTrigger value="updates">可更新 ({getFilteredPluginCount('updates')})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 进度条 - 仅显示插件清单加载进度 */}
|
||||||
|
{loadProgress && loadProgress.stage === 'loading' && loadProgress.operation === 'fetch' && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">加载插件列表</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{loadProgress.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={loadProgress.progress} className="h-2" />
|
||||||
|
<div className="text-xs text-muted-foreground">{loadProgress.message}</div>
|
||||||
|
{loadProgress.total_plugins > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
|
已加载 {loadProgress.loaded_plugins} / {loadProgress.total_plugins} 个插件
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载错误显示 */}
|
||||||
|
{loadProgress && loadProgress.stage === 'error' && loadProgress.error && (
|
||||||
|
<Card className="border-destructive bg-destructive/10">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-destructive">加载失败</CardTitle>
|
||||||
|
<CardDescription className="text-destructive/80">{loadProgress.error}</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 插件卡片网格 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-3 text-muted-foreground">加载插件列表中...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<AlertTriangle className="h-12 w-12 text-destructive mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold mb-2">加载失败</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">{error}</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>重新加载</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : activeTab === 'all' ? (
|
||||||
|
<MarketplaceTab
|
||||||
|
plugins={plugins}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
categoryFilter={categoryFilter}
|
||||||
|
showCompatibleOnly={showCompatibleOnly}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
maimaiVersion={maimaiVersion}
|
||||||
|
pluginStats={pluginStats}
|
||||||
|
loadProgress={loadProgress}
|
||||||
|
onInstall={openInstallDialog}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
checkPluginCompatibility={checkPluginCompatibility}
|
||||||
|
needsUpdate={needsUpdate}
|
||||||
|
getStatusBadge={getStatusBadge}
|
||||||
|
getIncompatibleReason={getIncompatibleReason}
|
||||||
|
/>
|
||||||
|
) : activeTab === 'installed' ? (
|
||||||
|
<InstalledTab
|
||||||
|
plugins={plugins}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
categoryFilter={categoryFilter}
|
||||||
|
showCompatibleOnly={showCompatibleOnly}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
maimaiVersion={maimaiVersion}
|
||||||
|
pluginStats={pluginStats}
|
||||||
|
loadProgress={loadProgress}
|
||||||
|
onInstall={openInstallDialog}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
checkPluginCompatibility={checkPluginCompatibility}
|
||||||
|
needsUpdate={needsUpdate}
|
||||||
|
getStatusBadge={getStatusBadge}
|
||||||
|
getIncompatibleReason={getIncompatibleReason}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-5">
|
||||||
|
{filteredUpdatablePlugins.map((plugin) => (
|
||||||
|
<PluginCard
|
||||||
|
key={plugin.id}
|
||||||
|
plugin={plugin}
|
||||||
|
gitStatus={gitStatus}
|
||||||
|
maimaiVersion={maimaiVersion}
|
||||||
|
pluginStats={pluginStats}
|
||||||
|
loadProgress={loadProgress}
|
||||||
|
onInstall={openInstallDialog}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onUninstall={handleUninstall}
|
||||||
|
checkPluginCompatibility={checkPluginCompatibility}
|
||||||
|
needsUpdate={needsUpdate}
|
||||||
|
getStatusBadge={getStatusBadge}
|
||||||
|
getIncompatibleReason={getIncompatibleReason}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 安装对话框 */}
|
||||||
|
<InstallDialog
|
||||||
|
open={installDialogOpen}
|
||||||
|
plugin={installingPlugin}
|
||||||
|
onOpenChange={setInstallDialogOpen}
|
||||||
|
onInstall={handleInstall}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 重启遮罩层 */}
|
||||||
|
<RestartOverlay />
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
dashboard/src/routes/plugins/types.ts
Normal file
18
dashboard/src/routes/plugins/types.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
'Group Management': '群组管理',
|
||||||
|
'Entertainment & Interaction': '娱乐互动',
|
||||||
|
'Utility Tools': '实用工具',
|
||||||
|
'Content Generation': '内容生成',
|
||||||
|
Multimedia: '多媒体',
|
||||||
|
'External Integration': '外部集成',
|
||||||
|
'Data Analysis & Insights': '数据分析与洞察',
|
||||||
|
Other: '其他',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出类型
|
||||||
|
export type { PluginInfo, GitStatus, MaimaiVersion, PluginLoadProgress, PluginStatsData }
|
||||||
@@ -8,6 +8,7 @@ services:
|
|||||||
- PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6
|
- PRIVACY_AGREE=9943b855e72199d0f5016ea39052f1b6
|
||||||
- MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1
|
- MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED=1
|
||||||
- MAIBOT_STATISTICS_REPORT_PATH=/MaiMBot/data/maibot_statistics.html
|
- MAIBOT_STATISTICS_REPORT_PATH=/MaiMBot/data/maibot_statistics.html
|
||||||
|
- MAIBOT_WEBUI_USE_LOCAL_DASHBOARD=1
|
||||||
ports:
|
ports:
|
||||||
- "18001:8001"
|
- "18001:8001"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Reference in New Issue
Block a user