Files
Foxel/web/src/pages/PluginsPage.tsx
2026-01-06 16:54:49 +08:00

961 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string | null | undefined> | 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 (
<div className="fx-center-card">
<div className="fx-center-card-hero">
<div
className="fx-center-card-iconbox"
style={onIconClick ? { cursor: 'pointer' } : undefined}
role={onIconClick ? 'button' : undefined}
tabIndex={onIconClick ? 0 : undefined}
onClick={onIconClick}
onKeyDown={onIconClick ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onIconClick();
}
} : undefined}
>
<img
className="fx-center-card-icon"
src={iconUrl}
alt={iconAlt}
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/logo.svg'; }}
/>
</div>
{pills && pills.length > 0 ? (
<div className="fx-center-card-pills">
{pills.slice(0, 2).map(pill => (
<span key={pill} className="fx-center-pill">
{pill}
</span>
))}
</div>
) : null}
</div>
<div className="fx-center-card-body">
<div className="fx-center-card-titleRow">
<div
className="fx-center-card-title"
title={typeof name === 'string' ? name : undefined}
style={onTitleClick ? { cursor: 'pointer' } : undefined}
role={onTitleClick ? 'button' : undefined}
tabIndex={onTitleClick ? 0 : undefined}
onClick={onTitleClick}
onKeyDown={onTitleClick ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onTitleClick();
}
} : undefined}
>
{name}
</div>
{version ? <div className="fx-center-card-version">{version}</div> : null}
</div>
{metaLeft || metaRight ? (
<div className="fx-center-card-metaRow">
<span className="fx-center-card-metaLeft">{metaLeft}</span>
<span className="fx-center-card-metaRight">{metaRight}</span>
</div>
) : null}
{description ? <div className="fx-center-card-desc">{description}</div> : null}
</div>
{footerLeft || footerRight ? (
<div className="fx-center-card-footer">
<div className="fx-center-card-footerLeft">{footerLeft}</div>
<div className="fx-center-card-footerRight">{footerRight}</div>
</div>
) : null}
</div>
);
});
const PluginsPage = memo(function PluginsPage() {
const [data, setData] = useState<PluginItem[]>([]);
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<UploadFile[]>([]);
const [installModalOpen, setInstallModalOpen] = useState(false);
const [installDone, setInstallDone] = useState(false);
const [installStopReason, setInstallStopReason] = useState<string | undefined>(undefined);
const [installFileState, setInstallFileState] = useState<Record<string, InstallState>>({});
// 应用发现相关状态
const [discoveryApps, setDiscoveryApps] = useState<FoxelCoreApp[]>([]);
const [discoveryLoading, setDiscoveryLoading] = useState(false);
const [discoveryError, setDiscoveryError] = useState<string | undefined>(undefined);
const [discoverySearch, setDiscoverySearch] = useState('');
const [downloadingApps, setDownloadingApps] = useState<Set<string>>(new Set());
// 应用详情弹窗状态
const [detailOpen, setDetailOpen] = useState(false);
const [detailKey, setDetailKey] = useState<string | null>(null);
const [detailSummary, setDetailSummary] = useState<FoxelCoreApp | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string | undefined>(undefined);
const [detailApp, setDetailApp] = useState<FoxelCoreAppDetail | null>(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<FoxelCoreApp, 'key' | 'version' | 'downloadUrl'>) => {
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 (
<CenterCard
key={p.key}
iconUrl={icon}
iconAlt={name}
name={name}
version={p.version || undefined}
description={texts.description || t('No description')}
metaLeft={p.author || undefined}
metaRight={p.key}
pills={buildCardPills(p.supported_exts, t('Any'))}
footerLeft={(p.github || p.website) ? (
<div className="fx-center-card-links">
{p.github ? (
<a className="fx-center-card-iconLink" href={p.github || undefined} target="_blank" rel="noreferrer" title="GitHub" aria-label="GitHub">
<GithubOutlined />
</a>
) : null}
{p.website ? (
<a className="fx-center-card-iconLink" href={p.website || undefined} target="_blank" rel="noreferrer" title={t('Website')} aria-label={t('Website')}>
<LinkOutlined />
</a>
) : null}
</div>
) : null}
footerRight={
<div className="fx-center-card-actions">
<Button
type="primary"
size="small"
disabled={!canOpenApp}
onClick={async () => {
let target = app || getAppByKey(appKey);
if (!target) {
await reloadPluginApps();
target = getAppByKey(appKey);
}
if (target?.openAppComponent) openApp(target);
}}
>
{t('Open App')}
</Button>
<Popconfirm title={t('Confirm delete this plugin?')} onConfirm={async () => { await pluginsApi.remove(p.key); await reload(); await reloadPluginApps(); }}>
<Button type="text" danger size="small">{t('Uninstall')}</Button>
</Popconfirm>
</div>
}
/>
);
};
return (
<div style={{ height: 'calc(100vh - 88px)', display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<Upload
accept=".foxpkg"
multiple
disabled={installing}
fileList={fileList}
onChange={({ fileList: newFileList }) => {
setFileList(newFileList);
if (newFileList.length > 0) {
setInstallModalOpen(true);
resetInstallUi();
}
}}
beforeUpload={() => false}
showUploadList={false}
>
<Button icon={<UploadOutlined />} type="primary">{t('Install Plugin')}</Button>
</Upload>
{tab === 'installed' && <Button onClick={reload} loading={loading} disabled={installing}>{t('Refresh')}</Button>}
<div style={{ marginLeft: 'auto' }} />
</div>
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as any)}
className="plugins-tabs"
items={[
{
key: 'installed',
label: t('Installed'),
children: (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input
type="text"
placeholder={t('Search name/author/extension')}
value={q}
onChange={e => 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,
}}
/>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: 4 }}>
{loading ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 320px))', gap: 12, justifyContent: 'start' }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} style={{ borderRadius: 10 }}>
<Skeleton active avatar paragraph={{ rows: 3 }} />
</Card>
))}
</div>
) : filtered.length === 0 ? (
<Empty description={t('No plugins')} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 320px))', gap: 12, justifyContent: 'start' }}>
{filtered.map(renderCard)}
</div>
)}
</div>
</div>
)
},
{
key: 'discover',
label: t('Discover'),
children: (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<input
type="text"
placeholder={t('Search apps')}
value={discoverySearch}
onChange={e => 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,
}}
/>
<Button
icon={<ReloadOutlined />}
onClick={loadDiscoveryApps}
loading={discoveryLoading}
>
{t('Refresh')}
</Button>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: 4 }}>
{discoveryLoading && discoveryApps.length === 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 320px))', gap: 12, justifyContent: 'start' }}>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} style={{ borderRadius: 10 }}>
<Skeleton active avatar paragraph={{ rows: 3 }} />
</Card>
))}
</div>
) : discoveryError ? (
<Empty description={discoveryError}>
<Button onClick={loadDiscoveryApps}>{t('Refresh')}</Button>
</Empty>
) : filteredDiscoveryApps.length === 0 ? (
<Empty description={discoveryApps.length === 0 ? t('No results') : t('No results')} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 320px))', gap: 12, justifyContent: 'start' }}>
{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 (
<CenterCard
key={app.key}
iconUrl={iconUrl}
iconAlt={name}
name={name}
version={app.version || undefined}
description={description}
metaLeft={app.author || undefined}
metaRight={lang === 'zh' ? `审核于 ${approvedDate}` : `Approved ${approvedDate}`}
pills={buildCardPills(tags)}
onIconClick={onOpenDetail}
onTitleClick={onOpenDetail}
footerLeft={app.website ? (
<a className="fx-center-card-iconLink" href={app.website} target="_blank" rel="noreferrer" title={t('Website')} aria-label={t('Website')}>
<LinkOutlined />
</a>
) : null}
footerRight={installed ? (
<span className="fx-center-pill fx-center-pill-success">{t('Installed already')}</span>
) : (
<Button
type="primary"
size="small"
icon={<DownloadOutlined />}
loading={downloading}
onClick={() => handleDownloadAndInstall(app)}
>
{downloading ? t('Downloading') : t('Download and Install')}
</Button>
)}
/>
);
})}
</div>
)}
</div>
</div>
)
}
]}
/>
<Modal
title={(() => {
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 (
<Space>
<Button onClick={closeDetailModal}>{t('Close')}</Button>
{installed ? (
<Button disabled>{t('Installed already')}</Button>
) : (
<Button
type="primary"
size="middle"
icon={<DownloadOutlined />}
loading={downloading}
disabled={!key || !downloadUrl || !version || detailLoading}
onClick={() => {
if (!key || !downloadUrl || !version) return;
void handleDownloadAndInstall({ key, version, downloadUrl });
}}
>
{t('Download and Install')}
</Button>
)}
</Space>
);
})()}
>
<div style={{ maxHeight: '70vh', overflowY: 'auto', paddingRight: 6 }}>
{detailLoading && !detailApp ? (
<Skeleton active avatar paragraph={{ rows: 8 }} />
) : detailError ? (
<Empty description={detailError}>
<Button
onClick={() => {
if (!detailKey) return;
setDetailApp(null);
setDetailError(undefined);
setDetailReloadId(v => v + 1);
}}
>
{t('Refresh')}
</Button>
</Empty>
) : detailApp ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<div style={{ width: 64, height: 64, borderRadius: 14, overflow: 'hidden', border: `1px solid ${token.colorBorder}` }}>
<img
src={`https://foxel.cc/api/apps/${encodeURIComponent(detailApp.key)}/icon`}
alt={lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en}
style={{ width: '100%', height: '100%', objectFit: 'contain', background: token.colorBgContainer }}
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/logo.svg'; }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en}
</Typography.Title>
<Typography.Text type="secondary">
{t('Version')}: {detailApp.latest.version}
</Typography.Text>
</div>
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 10, color: token.colorTextTertiary, fontSize: 12 }}>
<span>{detailApp.latest.author}</span>
<span>{detailApp.key}</span>
<span>
{lang === 'zh'
? `审核于 ${new Date(detailApp.latest.approvedAt).toLocaleDateString('zh-CN')}`
: `Approved ${new Date(detailApp.latest.approvedAt).toLocaleDateString('en-US')}`}
</span>
{detailApp.latest.website ? (
<a href={detailApp.latest.website} target="_blank" rel="noreferrer" style={{ color: token.colorTextTertiary }}>
<LinkOutlined style={{ marginRight: 6 }} />
{t('Website')}
</a>
) : null}
</div>
<div style={{ marginTop: 10 }}>
<Space size={[4, 4]} wrap>
{(lang === 'zh' ? detailApp.latest.tags.zh : detailApp.latest.tags.en).map(tag => (
<Tag key={tag}>{tag}</Tag>
))}
</Space>
</div>
</div>
</div>
<Tabs
defaultActiveKey="details"
items={[
{
key: 'details',
label: t('Details'),
children: (
<Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
{lang === 'zh' ? detailApp.latest.description.zh : detailApp.latest.description.en}
</Typography.Paragraph>
),
},
{
key: 'versions',
label: t('Version'),
children: (
<Collapse
size="small"
items={detailApp.versions.map(v => {
const approvedDate = new Date(v.approvedAt).toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US');
return {
key: v.version,
label: (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.version}</span>
<span style={{ color: token.colorTextTertiary, fontSize: 12 }}>
{lang === 'zh' ? `审核于 ${approvedDate}` : `Approved ${approvedDate}`}
</span>
</div>
),
children: v.releaseNotesMd ? (
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
<ReactMarkdown>{v.releaseNotesMd}</ReactMarkdown>
</div>
) : (
<Typography.Text type="secondary">
{lang === 'zh' ? '暂无更新记录' : 'No release notes'}
</Typography.Text>
),
};
})}
style={{ background: 'transparent' }}
/>
),
},
]}
/>
</div>
) : (
<Empty description={t('No results')} />
)}
</div>
</Modal>
<Modal
title={t('Confirm Install')}
open={installModalOpen}
onCancel={closeInstallModal}
maskClosable={!installing}
closable={!installing}
width={640}
footer={(() => {
if (fileList.length === 0) {
return <Button type="primary" onClick={closeInstallModal}>{t('Close')}</Button>;
}
if (installing) {
return <Button type="primary" loading>{t('Installing')}</Button>;
}
if (installDone) {
return <Button type="primary" onClick={closeInstallModal}>{t('Close')}</Button>;
}
return (
<Space>
<Button onClick={closeInstallModal}>{t('Cancel')}</Button>
<Button type="primary" onClick={handleInstall}>{t('Confirm Install')}</Button>
</Space>
);
})()}
>
{fileList.length === 0 ? (
<Empty description={t('Please select a .foxpkg file')} />
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<Typography.Text>{t('Selected {count} files', { count: fileList.length })}</Typography.Text>
<div style={{ marginTop: 4 }}>
<Typography.Text type="secondary">{t('Installation will stop on first failure')}</Typography.Text>
</div>
</div>
{installStopReason ? (
<Alert type="error" showIcon message={installStopReason} />
) : null}
<Progress
percent={(() => {
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}`;
}}
/>
<List
dataSource={fileList}
bordered
renderItem={(file) => {
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 (
<List.Item
actions={[
(!installing && !installDone) ? (
<Button key="remove" type="link" danger onClick={() => removeSelectedFile(file.uid)}>
{t('Remove')}
</Button>
) : null,
<Tag key="status" color={statusColor}>{statusText}</Tag>,
].filter(Boolean) as any}
>
<List.Item.Meta
title={file.name}
description={
(msg || errors.length > 0) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{msg ? (
<Typography.Text type={st === 'failed' ? 'danger' : (st === 'success' ? 'success' : 'secondary')}>
{msg}
</Typography.Text>
) : null}
{errors.length > 0 ? (
<Typography.Text type={st === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
{errors.join('; ')}
</Typography.Text>
) : null}
</div>
) : null
}
/>
</List.Item>
);
}}
/>
</div>
)}
</Modal>
</div>
);
});
export default PluginsPage;