mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-08 07:41:57 +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',
|
'Install': 'Install',
|
||||||
'App URL': 'App URL',
|
'App URL': 'App URL',
|
||||||
'Please input a valid URL': 'Please input a valid 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
|
// Setup page
|
||||||
'Initialization succeeded! Logging you in...': 'Initialization succeeded! Logging you in...',
|
'Initialization succeeded! Logging you in...': 'Initialization succeeded! Logging you in...',
|
||||||
|
|||||||
@@ -318,6 +318,14 @@ export const zh = {
|
|||||||
'Install': '安装',
|
'Install': '安装',
|
||||||
'App URL': '应用链接',
|
'App URL': '应用链接',
|
||||||
'Please input a valid URL': '请输入合法的 URL',
|
'Please input a valid URL': '请输入合法的 URL',
|
||||||
|
'Installed': '已安装',
|
||||||
|
'Discover': '发现',
|
||||||
|
'Search apps': '搜索应用',
|
||||||
|
'Sort by': '排序',
|
||||||
|
'Downloads': '下载量',
|
||||||
|
'Created (newest)': '创建时间(最新)',
|
||||||
|
'Installed already': '已安装',
|
||||||
|
'No results': '暂无结果',
|
||||||
|
|
||||||
// Setup page
|
// Setup page
|
||||||
'Initialization succeeded! Logging you in...': '初始化成功!正在为您登录,请不要刷新。',
|
'Initialization succeeded! Logging you in...': '初始化成功!正在为您登录,请不要刷新。',
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { memo, useEffect, useMemo, useState } from 'react';
|
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 { GithubOutlined, LinkOutlined } from '@ant-design/icons';
|
||||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||||
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
|
import { loadPluginFromUrl, ensureManifest } from '../plugins/runtime';
|
||||||
import { reloadPluginApps } from '../apps/registry';
|
import { reloadPluginApps } from '../apps/registry';
|
||||||
import { useI18n } from '../i18n';
|
import { useI18n } from '../i18n';
|
||||||
|
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter';
|
||||||
|
|
||||||
const PluginsPage = memo(function PluginsPage() {
|
const PluginsPage = memo(function PluginsPage() {
|
||||||
const [data, setData] = useState<PluginItem[]>([]);
|
const [data, setData] = useState<PluginItem[]>([]);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [q, setQ] = useState('');
|
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 [form] = Form.useForm<{ url: string }>();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -21,6 +31,30 @@ const PluginsPage = memo(function PluginsPage() {
|
|||||||
|
|
||||||
useEffect(() => { reload(); }, []);
|
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 () => {
|
const handleAdd = async () => {
|
||||||
try {
|
try {
|
||||||
const { url } = await form.validateFields();
|
const { url } = await form.validateFields();
|
||||||
@@ -82,9 +116,11 @@ const PluginsPage = memo(function PluginsPage() {
|
|||||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
||||||
{p.description || '(暂无描述)'}
|
{p.description || '(暂无描述)'}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e}>{e}</Tag>)}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'nowrap', overflow: 'hidden', whiteSpace: 'nowrap', minWidth: 0, flex: 1 }}>
|
||||||
{more > 0 && <Tag>+{more}</Tag>}
|
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e} style={{ flex: 'none' }}>{e}</Tag>)}
|
||||||
|
</div>
|
||||||
|
{more > 0 && <Tag style={{ flex: 'none' }}>+{more}</Tag>}
|
||||||
</div>
|
</div>
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
{(p.author || p.github || p.website) && (
|
{(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
<Button type="primary" onClick={() => setAdding(true)}>{t('Install App')}</Button>
|
<Button type="primary" onClick={() => setAdding(true)}>{t('Install App')}</Button>
|
||||||
<Button onClick={reload} loading={loading}>{t('Refresh')}</Button>
|
{tab === 'installed' && <Button onClick={reload} loading={loading}>{t('Refresh')}</Button>}
|
||||||
<Input
|
<div style={{ marginLeft: 'auto' }} />
|
||||||
placeholder={t('Search name/author/url/extension')}
|
|
||||||
value={q}
|
|
||||||
onChange={e => setQ(e.target.value)}
|
|
||||||
allowClear
|
|
||||||
style={{ maxWidth: 320, marginLeft: 'auto' }}
|
|
||||||
onPressEnter={() => reload()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
<Tabs
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
activeKey={tab}
|
||||||
<Card key={i} style={{ borderRadius: 10 }}>
|
onChange={(k) => setTab(k as any)}
|
||||||
<Skeleton active avatar paragraph={{ rows: 3 }} />
|
items={[
|
||||||
</Card>
|
{
|
||||||
))}
|
key: 'installed',
|
||||||
</div>
|
label: t('Installed'),
|
||||||
) : filtered.length === 0 ? (
|
children: (
|
||||||
<Empty description={t('No plugins')} />
|
<>
|
||||||
) : (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
<Input
|
||||||
{filtered.map(renderCard)}
|
placeholder={t('Search name/author/url/extension')}
|
||||||
</div>
|
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
|
<Modal
|
||||||
title={t('Install App')}
|
title={t('Install App')}
|
||||||
open={adding}
|
open={adding}
|
||||||
|
|||||||
Reference in New Issue
Block a user