From 427a4f023f77e2bfbb30d95178774d78d2a4dbd9 Mon Sep 17 00:00:00 2001 From: shiyu Date: Thu, 11 Sep 2025 21:11:17 +0800 Subject: [PATCH] feat: Add plugin center functionality --- web/src/api/pluginCenter.ts | 52 +++++++ web/src/i18n/locales/en.ts | 8 ++ web/src/i18n/locales/zh.ts | 8 ++ web/src/pages/PluginsPage.tsx | 255 ++++++++++++++++++++++++++++++---- 4 files changed, 295 insertions(+), 28 deletions(-) create mode 100644 web/src/api/pluginCenter.ts diff --git a/web/src/api/pluginCenter.ts b/web/src/api/pluginCenter.ts new file mode 100644 index 0000000..7dfabd8 --- /dev/null +++ b/web/src/api/pluginCenter.ts @@ -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 { + 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(); +} + diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 7cd3574..5119072 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -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...', diff --git a/web/src/i18n/locales/zh.ts b/web/src/i18n/locales/zh.ts index 6705774..7a3551b 100644 --- a/web/src/i18n/locales/zh.ts +++ b/web/src/i18n/locales/zh.ts @@ -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...': '初始化成功!正在为您登录,请不要刷新。', diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index d4b8efb..6b4a03e 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -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([]); 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([]); + const [installingKeys, setInstallingKeys] = useState>({}); 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(); + 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() { {p.description || '(暂无描述)'} -
- {(exts.length > 0 ? exts : ['任意']).map(e => {e})} - {more > 0 && +{more}} +
+
+ {(exts.length > 0 ? exts : ['任意']).map(e => {e})} +
+ {more > 0 && +{more}}
{(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 = ( +
+ {name} { (e.currentTarget as HTMLImageElement).src = '/plugins/demo-text-viewer.svg'; }} /> + {name} + {item.version && {item.version}} +
+ ); + return ( + + {t('Downloads')}: {item.downloads} + + ) : ( + + ), + + ]} + > + + {item.description || '(暂无描述)'} + +
+
+ {(exts.length > 0 ? exts : ['任意']).map(e => {e})} +
+ {more > 0 && +{more}} +
+ + {(item.author || item.github || item.website) && ( +
+ {item.author && {t('Author')}: {item.author}} + + {item.github && ( + + + + )} + {item.website && ( + + + + )} + +
+ )} +
+ ); + }; + return ( <>
- - setQ(e.target.value)} - allowClear - style={{ maxWidth: 320, marginLeft: 'auto' }} - onPressEnter={() => reload()} - /> + {tab === 'installed' && } +
- {loading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - - - ))} -
- ) : filtered.length === 0 ? ( - - ) : ( -
- {filtered.map(renderCard)} -
- )} + + setTab(k as any)} + items={[ + { + key: 'installed', + label: t('Installed'), + children: ( + <> +
+ setQ(e.target.value)} + allowClear + style={{ maxWidth: 360 }} + onPressEnter={() => reload()} + /> +
+ {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + + + ))} +
+ ) : filtered.length === 0 ? ( + + ) : ( +
+ {filtered.map(renderCard)} +
+ )} + + ) + }, + { + key: 'discover', + label: t('Discover'), + children: ( + <> +
+ { setRepoQ(e.target.value); setRepoPage(1); }} + allowClear + style={{ maxWidth: 360 }} + onPressEnter={() => { setRepoPage(1); reloadRepo(); }} + /> +