import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; import { Alert, Button, Card, Collapse, Empty, List, Modal, Popconfirm, Progress, Skeleton, Space, Tabs, Tag, Typography, Upload, message, theme } from 'antd'; import { GithubOutlined, LinkOutlined, UploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons'; import { pluginsApi, type PluginItem } from '../api/plugins'; import { getAppByKey, reloadPluginApps } from '../apps/registry'; import { useI18n } from '../i18n'; import { useAppWindows } from '../contexts/AppWindowsContext'; import { getPluginAssetUrl } from '../plugins/runtime'; import { fetchFoxelCoreAppDetail, fetchFoxelCoreApps, downloadFoxelCoreApp, type FoxelCoreApp, type FoxelCoreAppDetail } from '../api/pluginCenter'; import type { UploadFile } from 'antd'; import ReactMarkdown from 'react-markdown'; type InstallStatus = 'pending' | 'installing' | 'success' | 'failed' | 'skipped'; type InstallState = Partial<{ status: InstallStatus; message: string; errors: string[]; }>; type CenterCardProps = { iconUrl: string; iconAlt: string; name: ReactNode; version?: ReactNode; description?: ReactNode; metaLeft?: ReactNode; metaRight?: ReactNode; pills?: string[]; footerLeft?: ReactNode; footerRight?: ReactNode; onIconClick?: () => void; onTitleClick?: () => void; }; function buildCardPills(items: Array | null | undefined, fallback?: string) { const cleaned = (items || []).map(v => (v || '').trim()).filter(Boolean); if (cleaned.length === 0) return fallback ? [fallback] : []; if (cleaned.length <= 2) return cleaned; return [cleaned[0], `+${cleaned.length - 1}`]; } const CenterCard = memo(function CenterCard(props: CenterCardProps) { const { iconUrl, iconAlt, name, version, description, metaLeft, metaRight, pills, footerLeft, footerRight, onIconClick, onTitleClick, } = props; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onIconClick(); } } : undefined} > {iconAlt} { (e.currentTarget as HTMLImageElement).src = '/logo.svg'; }} />
{pills && pills.length > 0 ? (
{pills.slice(0, 2).map(pill => ( {pill} ))}
) : null}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onTitleClick(); } } : undefined} > {name}
{version ?
{version}
: null}
{metaLeft || metaRight ? (
{metaLeft} {metaRight}
) : null} {description ?
{description}
: null}
{footerLeft || footerRight ? (
{footerLeft}
{footerRight}
) : null}
); }); const PluginsPage = memo(function PluginsPage() { const [data, setData] = useState([]); const [installing, setInstalling] = useState(false); const [loading, setLoading] = useState(false); const [q, setQ] = useState(''); const [tab, setTab] = useState<'installed' | 'discover'>('installed'); const [fileList, setFileList] = useState([]); const [installModalOpen, setInstallModalOpen] = useState(false); const [installDone, setInstallDone] = useState(false); const [installStopReason, setInstallStopReason] = useState(undefined); const [installFileState, setInstallFileState] = useState>({}); // 应用发现相关状态 const [discoveryApps, setDiscoveryApps] = useState([]); const [discoveryLoading, setDiscoveryLoading] = useState(false); const [discoveryError, setDiscoveryError] = useState(undefined); const [discoverySearch, setDiscoverySearch] = useState(''); const [downloadingApps, setDownloadingApps] = useState>(new Set()); // 应用详情弹窗状态 const [detailOpen, setDetailOpen] = useState(false); const [detailKey, setDetailKey] = useState(null); const [detailSummary, setDetailSummary] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(undefined); const [detailApp, setDetailApp] = useState(null); const [detailReloadId, setDetailReloadId] = useState(0); const { token } = theme.useToken(); const { t, lang } = useI18n(); const { openApp } = useAppWindows(); const reload = useCallback(async () => { try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); } }, []); useEffect(() => { void reload(); }, [reload]); // 加载应用发现列表 const loadDiscoveryApps = useCallback(async () => { try { setDiscoveryLoading(true); setDiscoveryError(undefined); const apps = await fetchFoxelCoreApps(); setDiscoveryApps(apps); } catch (e: any) { setDiscoveryError(e?.message || t('Failed to load apps')); } finally { setDiscoveryLoading(false); } }, [t]); useEffect(() => { if (tab === 'discover' && discoveryApps.length === 0 && !discoveryError) { void loadDiscoveryApps(); } }, [discoveryApps.length, discoveryError, loadDiscoveryApps, tab]); const closeDetailModal = () => { setDetailOpen(false); setDetailKey(null); setDetailSummary(null); setDetailLoading(false); setDetailError(undefined); setDetailApp(null); }; const openDiscoveryAppDetail = useCallback((app: FoxelCoreApp) => { setDetailOpen(true); setDetailKey(app.key); setDetailSummary(app); setDetailError(undefined); setDetailApp(null); }, []); useEffect(() => { if (!detailOpen || !detailKey) return; let cancelled = false; const currentKey = detailKey; const run = async () => { setDetailLoading(true); setDetailError(undefined); try { const app = await fetchFoxelCoreAppDetail(currentKey); if (cancelled) return; setDetailApp(app); } catch (e: any) { if (cancelled) return; setDetailError(e?.message || t('Load failed')); } finally { if (!cancelled) setDetailLoading(false); } }; void run(); return () => { cancelled = true; }; }, [detailKey, detailOpen, detailReloadId, t]); const resetInstallUi = () => { setInstallDone(false); setInstallStopReason(undefined); setInstallFileState({}); }; const closeInstallModal = () => { if (installing) return; setInstallModalOpen(false); setFileList([]); resetInstallUi(); }; const removeSelectedFile = (uid: string) => { if (installing || installDone) return; setFileList(prev => { const next = prev.filter(f => f.uid !== uid); if (next.length === 0) { setInstallModalOpen(false); resetInstallUi(); } return next; }); setInstallFileState(prev => { const next = { ...prev }; delete next[uid]; return next; }); }; const handleInstall = async () => { if (fileList.length === 0) { message.warning(t('Please select a .foxpkg file')); return; } const setState = (uid: string, patch: InstallState) => { setInstallFileState(prev => ({ ...prev, [uid]: { ...(prev[uid] || {}), ...patch }, })); }; try { setInstalling(true); setInstallDone(false); setInstallStopReason(undefined); let stopReason: string | undefined; for (let i = 0; i < fileList.length; i++) { const file = fileList[i]; const uid = file.uid; setState(uid, { status: 'installing' }); if (!file.originFileObj) { stopReason = t('Invalid file'); setState(uid, { status: 'failed', message: stopReason }); for (let j = i + 1; j < fileList.length; j++) { setState(fileList[j].uid, { status: 'skipped' }); } break; } try { const result = await pluginsApi.install(file.originFileObj); if (result.success) { setState(uid, { status: 'success', message: result.message || t('Installed successfully'), errors: result.errors, }); continue; } stopReason = result.message || t('Installation failed'); setState(uid, { status: 'failed', message: stopReason, errors: result.errors, }); for (let j = i + 1; j < fileList.length; j++) { setState(fileList[j].uid, { status: 'skipped' }); } break; } catch (e: any) { stopReason = e?.message || t('Installation failed'); setState(uid, { status: 'failed', message: stopReason }); for (let j = i + 1; j < fileList.length; j++) { setState(fileList[j].uid, { status: 'skipped' }); } break; } } await reload(); await reloadPluginApps(); setInstallStopReason(stopReason); setInstallDone(true); if (stopReason) { message.error(stopReason); } else { message.success(t('Installed successfully')); } } catch (e: any) { const msg = e?.message || t('Installation failed'); setInstallStopReason(msg); setInstallDone(true); message.error(msg); } finally { setInstalling(false); } }; /** * 解析插件图标 URL */ const resolvePluginIcon = (p: PluginItem): string => { if (!p.icon) return '/logo.svg'; if (p.icon.startsWith('http://') || p.icon.startsWith('https://') || p.icon.startsWith('/')) { return p.icon; } return getPluginAssetUrl(p.key, p.icon); }; /** * 按当前语言解析插件文案(name/description) */ const resolvePluginTexts = useCallback((p: PluginItem): { name?: string; description?: string } => { const i18n = (p.manifest as any)?.i18n as any; const entry = i18n?.[lang] as any; return { name: entry?.name || p.name || undefined, description: entry?.description || p.description || undefined, }; }, [lang]); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); if (!s) return data; return data.filter(p => ( (resolvePluginTexts(p).name || '').toLowerCase().includes(s) || (p.author || '').toLowerCase().includes(s) || (p.key || '').toLowerCase().includes(s) || (resolvePluginTexts(p).description || '').toLowerCase().includes(s) || (p.supported_exts || []).some(e => e.toLowerCase().includes(s)) )); }, [data, q, resolvePluginTexts]); // 过滤应用发现列表 const filteredDiscoveryApps = useMemo(() => { const s = discoverySearch.trim().toLowerCase(); if (!s) return discoveryApps; return discoveryApps.filter(app => { const name = (lang === 'zh' ? app.name.zh : app.name.en).toLowerCase(); const desc = (lang === 'zh' ? app.description.zh : app.description.en).toLowerCase(); const author = (app.author || '').toLowerCase(); const tags = lang === 'zh' ? app.tags.zh : app.tags.en; return ( name.includes(s) || desc.includes(s) || author.includes(s) || tags.some(tag => tag.toLowerCase().includes(s)) || app.key.toLowerCase().includes(s) ); }); }, [discoveryApps, discoverySearch, lang]); // 检查应用是否已安装 const isAppInstalled = (appKey: string) => { return data.some(p => p.key === appKey); }; // 下载并安装应用 const handleDownloadAndInstall = async (app: Pick) => { if (downloadingApps.has(app.key)) return; try { setDownloadingApps(prev => new Set(prev).add(app.key)); message.loading({ content: t('Downloading'), key: app.key, duration: 0 }); const file = await downloadFoxelCoreApp(app); message.destroy(app.key); message.loading({ content: t('Installing'), key: app.key, duration: 0 }); const result = await pluginsApi.install(file); message.destroy(app.key); if (result.success) { message.success(t('Installed successfully')); await reload(); await reloadPluginApps(); } else { message.error(result.message || t('Installation failed')); } } catch (e: any) { message.destroy(app.key); message.error(e?.message || t('Installation failed')); } finally { setDownloadingApps(prev => { const next = new Set(prev); next.delete(app.key); return next; }); } }; const renderCard = (p: PluginItem) => { const texts = resolvePluginTexts(p); const icon = resolvePluginIcon(p); const name = texts.name || `${t('Plugin')} ${p.key}`; const appKey = `plugin:${p.key}`; const app = getAppByKey(appKey); const canOpenApp = !!p.open_app; return ( {p.github ? ( ) : null} {p.website ? ( ) : null} ) : null} footerRight={
{ await pluginsApi.remove(p.key); await reload(); await reloadPluginApps(); }}>
} /> ); }; return (
{ setFileList(newFileList); if (newFileList.length > 0) { setInstallModalOpen(true); resetInstallUi(); } }} beforeUpload={() => false} showUploadList={false} > {tab === 'installed' && }
setTab(k as any)} className="plugins-tabs" items={[ { key: 'installed', label: t('Installed'), children: (
setQ(e.target.value)} style={{ maxWidth: 360, padding: '4px 11px', borderRadius: 6, border: `1px solid ${token.colorBorder}`, outline: 'none', background: token.colorBgContainer, color: token.colorText, }} />
{loading ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : filtered.length === 0 ? ( ) : (
{filtered.map(renderCard)}
)}
) }, { key: 'discover', label: t('Discover'), children: (
setDiscoverySearch(e.target.value)} style={{ maxWidth: 360, padding: '4px 11px', borderRadius: 6, border: `1px solid ${token.colorBorder}`, outline: 'none', background: token.colorBgContainer, color: token.colorText, }} />
{discoveryLoading && discoveryApps.length === 0 ? (
{Array.from({ length: 6 }).map((_, i) => ( ))}
) : discoveryError ? ( ) : filteredDiscoveryApps.length === 0 ? ( ) : (
{filteredDiscoveryApps.map(app => { const name = lang === 'zh' ? app.name.zh : app.name.en; const description = lang === 'zh' ? app.description.zh : app.description.en; const tags = lang === 'zh' ? app.tags.zh : app.tags.en; const iconUrl = `https://foxel.cc/api/apps/${encodeURIComponent(app.key)}/icon`; const installed = isAppInstalled(app.key); const downloading = downloadingApps.has(app.key); const approvedDate = new Date(app.approvedAt).toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US'); const onOpenDetail = () => openDiscoveryAppDetail(app); return ( ) : null} footerRight={installed ? ( {t('Installed already')} ) : ( )} /> ); })}
)}
) } ]} /> { if (detailApp) { return `${lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en} · ${t('Details')}`; } if (detailSummary) { return `${lang === 'zh' ? detailSummary.name.zh : detailSummary.name.en} · ${t('Details')}`; } return t('Details'); })()} open={detailOpen} onCancel={closeDetailModal} width={860} footer={(() => { const key = detailKey || detailApp?.key || detailSummary?.key; const installed = key ? isAppInstalled(key) : false; const downloading = key ? downloadingApps.has(key) : false; const downloadUrl = detailApp?.latest.downloadUrl || detailSummary?.downloadUrl; const version = detailApp?.latest.version || detailSummary?.version; return ( {installed ? ( ) : ( )} ); })()} >
{detailLoading && !detailApp ? ( ) : detailError ? ( ) : detailApp ? (
{lang { (e.currentTarget as HTMLImageElement).src = '/logo.svg'; }} />
{lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en} {t('Version')}: {detailApp.latest.version}
{detailApp.latest.author} {detailApp.key} {lang === 'zh' ? `审核于 ${new Date(detailApp.latest.approvedAt).toLocaleDateString('zh-CN')}` : `Approved ${new Date(detailApp.latest.approvedAt).toLocaleDateString('en-US')}`} {detailApp.latest.website ? ( {t('Website')} ) : null}
{(lang === 'zh' ? detailApp.latest.tags.zh : detailApp.latest.tags.en).map(tag => ( {tag} ))}
{lang === 'zh' ? detailApp.latest.description.zh : detailApp.latest.description.en} ), }, { key: 'versions', label: t('Version'), children: ( { const approvedDate = new Date(v.approvedAt).toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US'); return { key: v.version, label: (
{v.version} {lang === 'zh' ? `审核于 ${approvedDate}` : `Approved ${approvedDate}`}
), children: v.releaseNotesMd ? (
{v.releaseNotesMd}
) : ( {lang === 'zh' ? '暂无更新记录' : 'No release notes'} ), }; })} style={{ background: 'transparent' }} /> ), }, ]} />
) : ( )}
{ if (fileList.length === 0) { return ; } if (installing) { return ; } if (installDone) { return ; } return ( ); })()} > {fileList.length === 0 ? ( ) : (
{t('Selected {count} files', { count: fileList.length })}
{t('Installation will stop on first failure')}
{installStopReason ? ( ) : null} { const finished = fileList.filter(f => { const status = installFileState[f.uid]?.status; return status === 'success' || status === 'failed'; }).length; return fileList.length === 0 ? 0 : Math.round((finished / fileList.length) * 100); })()} status={installStopReason ? 'exception' : (installDone ? 'success' : (installing ? 'active' : 'normal'))} format={() => { const finished = fileList.filter(f => { const status = installFileState[f.uid]?.status; return status === 'success' || status === 'failed'; }).length; return `${finished}/${fileList.length}`; }} /> { const st = (installFileState[file.uid]?.status || 'pending') as InstallStatus; const msg = installFileState[file.uid]?.message; const errors = installFileState[file.uid]?.errors || []; const statusText = (() => { switch (st) { case 'pending': return t('Pending'); case 'installing': return t('Installing'); case 'success': return t('Success'); case 'failed': return t('Failed'); case 'skipped': return t('Skipped'); default: return t('Pending'); } })(); const statusColor = (() => { switch (st) { case 'installing': return 'blue'; case 'success': return 'green'; case 'failed': return 'red'; case 'skipped': return 'orange'; default: return 'default'; } })(); return ( removeSelectedFile(file.uid)}> {t('Remove')} ) : null, {statusText}, ].filter(Boolean) as any} > 0) ? (
{msg ? ( {msg} ) : null} {errors.length > 0 ? ( {errors.join('; ')} ) : null}
) : null } />
); }} />
)}
); }); export default PluginsPage;