mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-26 09:41:56 +08:00
feat: Add plugin center functionality
This commit is contained in:
52
web/src/api/pluginCenter.ts
Normal file
52
web/src/api/pluginCenter.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface RepoItem {
|
||||
key: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
icon?: string;
|
||||
supportedExts?: string[];
|
||||
createdAt?: number;
|
||||
downloads?: number;
|
||||
directUrl: string;
|
||||
}
|
||||
|
||||
export interface RepoListResponse {
|
||||
items: RepoItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface RepoQueryParams {
|
||||
query?: string;
|
||||
author?: string;
|
||||
sort?: 'downloads' | 'createdAt';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
const CENTER_BASE = 'https://center.foxel.cc';
|
||||
|
||||
export function buildCenterUrl(path: string) {
|
||||
return new URL(path, CENTER_BASE).href;
|
||||
}
|
||||
|
||||
export async function fetchRepoList(params: RepoQueryParams = {}): Promise<RepoListResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.query) query.set('query', params.query);
|
||||
if (params.author) query.set('author', params.author);
|
||||
if (params.sort) query.set('sort', params.sort);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? 12));
|
||||
|
||||
const url = `${CENTER_BASE}/api/repo?${query.toString()}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Repo fetch failed: ${resp.status}`);
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
@@ -316,6 +316,14 @@ export const en = {
|
||||
'Install': 'Install',
|
||||
'App URL': 'App URL',
|
||||
'Please input a valid URL': 'Please input a valid URL',
|
||||
'Installed': 'Installed',
|
||||
'Discover': 'Discover',
|
||||
'Search apps': 'Search apps',
|
||||
'Sort by': 'Sort by',
|
||||
'Downloads': 'Downloads',
|
||||
'Created (newest)': 'Created (newest)',
|
||||
'Installed already': 'Installed',
|
||||
'No results': 'No results',
|
||||
|
||||
// Setup page
|
||||
'Initialization succeeded! Logging you in...': 'Initialization succeeded! Logging you in...',
|
||||
|
||||
@@ -318,6 +318,14 @@ export const zh = {
|
||||
'Install': '安装',
|
||||
'App URL': '应用链接',
|
||||
'Please input a valid URL': '请输入合法的 URL',
|
||||
'Installed': '已安装',
|
||||
'Discover': '发现',
|
||||
'Search apps': '搜索应用',
|
||||
'Sort by': '排序',
|
||||
'Downloads': '下载量',
|
||||
'Created (newest)': '创建时间(最新)',
|
||||
'Installed already': '已安装',
|
||||
'No results': '暂无结果',
|
||||
|
||||
// Setup page
|
||||
'Initialization succeeded! Logging you in...': '初始化成功!正在为您登录,请不要刷新。',
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider } from 'antd';
|
||||
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider, Tabs, Select, Pagination } from 'antd';
|
||||
import { GithubOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
|
||||
import { reloadPluginApps } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
|
||||
|
||||
const PluginsPage = memo(function PluginsPage() {
|
||||
const [data, setData] = useState<PluginItem[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [tab, setTab] = useState<'installed' | 'discover'>('installed');
|
||||
const [repoLoading, setRepoLoading] = useState(false);
|
||||
const [repoQ, setRepoQ] = useState('');
|
||||
const [repoSort, setRepoSort] = useState<'createdAt' | 'downloads'>('createdAt');
|
||||
const [repoPage, setRepoPage] = useState(1);
|
||||
const [repoPageSize, setRepoPageSize] = useState(12);
|
||||
const [repoTotal, setRepoTotal] = useState(0);
|
||||
const [repoItems, setRepoItems] = useState<RepoItem[]>([]);
|
||||
const [installingKeys, setInstallingKeys] = useState<Record<string, boolean>>({});
|
||||
const [form] = Form.useForm<{ url: string }>();
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
@@ -21,6 +31,30 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
|
||||
useEffect(() => { reload(); }, []);
|
||||
|
||||
const installedKeySet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach(p => { if (p.key) set.add(p.key); });
|
||||
return set;
|
||||
}, [data]);
|
||||
|
||||
const reloadRepo = async () => {
|
||||
try {
|
||||
setRepoLoading(true);
|
||||
const res = await fetchRepoList({ query: repoQ || undefined, sort: repoSort, page: repoPage, pageSize: repoPageSize });
|
||||
setRepoItems(res.items || []);
|
||||
setRepoTotal(res.total || 0);
|
||||
} catch (e) {
|
||||
setRepoItems([]);
|
||||
setRepoTotal(0);
|
||||
} finally {
|
||||
setRepoLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'discover') reloadRepo();
|
||||
}, [tab, repoQ, repoSort, repoPage, repoPageSize]);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const { url } = await form.validateFields();
|
||||
@@ -82,9 +116,11 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
||||
{p.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e}>{e}</Tag>)}
|
||||
{more > 0 && <Tag>+{more}</Tag>}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
|
||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
|
||||
</div>
|
||||
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{(p.author || p.github || p.website) && (
|
||||
@@ -110,35 +146,198 @@ const PluginsPage = memo(function PluginsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderRepoCard = (item: RepoItem) => {
|
||||
const icon = item.icon || '/plugins/demo-text-viewer.svg';
|
||||
const name = item.name || item.key;
|
||||
const exts = (item.supportedExts || []).slice(0, 6);
|
||||
const more = (item.supportedExts || []).length - exts.length;
|
||||
const installed = installedKeySet.has(item.key);
|
||||
const installing = !!installingKeys[item.key];
|
||||
const title = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<img src={icon} alt={name} style={{ width: 24, height: 24, objectFit: 'contain' }} onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} />
|
||||
<span>{name}</span>
|
||||
{item.version && <Tag color="blue" style={{ marginLeft: 'auto' }}>{item.version}</Tag>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={item.key + '@' + (item.version || '')}
|
||||
title={title}
|
||||
hoverable
|
||||
size="small"
|
||||
styles={{ body: { padding: 12 } } as any}
|
||||
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
|
||||
actions={[
|
||||
typeof item.downloads === 'number' ? (
|
||||
<span key="dl" style={{ color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{t('Downloads')}: {item.downloads}
|
||||
</span>
|
||||
) : (
|
||||
<span key="dl-gap" />
|
||||
),
|
||||
<Button
|
||||
key="install"
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={installed || installing}
|
||||
loading={installing}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setInstallingKeys(s => ({ ...s, [item.key]: true }));
|
||||
const url = buildCenterUrl(item.directUrl);
|
||||
const created = await pluginsApi.create({ url });
|
||||
try {
|
||||
const p = await loadPluginFromUrl(created.url);
|
||||
await ensureManifest(created.id, p);
|
||||
} catch {}
|
||||
await reload();
|
||||
await reloadPluginApps();
|
||||
message.success(t('Installed successfully'));
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || 'Install failed');
|
||||
} finally {
|
||||
setInstallingKeys(s => ({ ...s, [item.key]: false }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{installed ? t('Installed already') : t('Install')}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
||||
{item.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
|
||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
|
||||
</div>
|
||||
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{(item.author || item.github || item.website) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{item.author && <span>{t('Author')}: {item.author}</span>}
|
||||
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{item.github && (
|
||||
<a href={item.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
|
||||
<GithubOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
{item.website && (
|
||||
<a href={item.website || undefined} target="_blank" rel="noreferrer" title={t('Website')}>
|
||||
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Button type="primary" onClick={() => setAdding(true)}>{t('Install App')}</Button>
|
||||
<Button onClick={reload} loading={loading}>{t('Refresh')}</Button>
|
||||
<Input
|
||||
placeholder={t('Search name/author/url/extension')}
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
allowClear
|
||||
style={{ maxWidth: 320, marginLeft: 'auto' }}
|
||||
onPressEnter={() => reload()}
|
||||
/>
|
||||
{tab === 'installed' && <Button onClick={reload} loading={loading}>{t('Refresh')}</Button>}
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{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(320px, 1fr))', gap: 12 }}>
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={(k) => setTab(k as any)}
|
||||
items={[
|
||||
{
|
||||
key: 'installed',
|
||||
label: t('Installed'),
|
||||
children: (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder={t('Search name/author/url/extension')}
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
allowClear
|
||||
style={{ maxWidth: 360 }}
|
||||
onPressEnter={() => reload()}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{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(320px, 1fr))', gap: 12 }}>
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'discover',
|
||||
label: t('Discover'),
|
||||
children: (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder={t('Search apps')}
|
||||
value={repoQ}
|
||||
onChange={e => { setRepoQ(e.target.value); setRepoPage(1); }}
|
||||
allowClear
|
||||
style={{ maxWidth: 360 }}
|
||||
onPressEnter={() => { setRepoPage(1); reloadRepo(); }}
|
||||
/>
|
||||
<Select
|
||||
value={repoSort}
|
||||
style={{ width: 200 }}
|
||||
onChange={(v) => { setRepoSort(v); setRepoPage(1); }}
|
||||
options={[
|
||||
{ value: 'createdAt', label: t('Created (newest)') },
|
||||
{ value: 'downloads', label: t('Downloads') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{repoLoading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} style={{ borderRadius: 10 }}>
|
||||
<Skeleton active avatar paragraph={{ rows: 3 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : repoItems.length === 0 ? (
|
||||
<Empty description={t('No results')} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{repoItems.map(renderRepoCard)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 12 }}>
|
||||
<Pagination
|
||||
current={repoPage}
|
||||
pageSize={repoPageSize}
|
||||
total={repoTotal}
|
||||
showSizeChanger
|
||||
pageSizeOptions={[12, 24, 48].map(String)}
|
||||
onChange={(p, ps) => { setRepoPage(p); setRepoPageSize(ps); }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('Install App')}
|
||||
open={adding}
|
||||
|
||||
Reference in New Issue
Block a user