From 4e16de973cfe5c6eab7bd1d3128f09442f765b8d Mon Sep 17 00:00:00 2001 From: shiyu Date: Tue, 6 Jan 2026 21:18:26 +0800 Subject: [PATCH] feat: add search functionality to fetchFoxelCoreApps and enhance PluginsPage with query handling --- web/src/api/pluginCenter.ts | 10 +++- web/src/contexts/AppWindowsContext.tsx | 21 ++++++- web/src/i18n/locales/en.json | 3 + web/src/i18n/locales/zh.json | 3 + web/src/pages/PluginsPage.tsx | 83 ++++++++++++++++---------- 5 files changed, 84 insertions(+), 36 deletions(-) diff --git a/web/src/api/pluginCenter.ts b/web/src/api/pluginCenter.ts index 35ea267..d52de0f 100644 --- a/web/src/api/pluginCenter.ts +++ b/web/src/api/pluginCenter.ts @@ -113,9 +113,13 @@ export async function fetchRepoList(params: RepoQueryParams = {}): Promise { - const url = `${FOXEL_CORE_BASE}/api/apps`; - const resp = await fetch(url); +export async function fetchFoxelCoreApps(query?: string): Promise { + const url = new URL('/api/apps', FOXEL_CORE_BASE); + const q = query?.trim(); + if (q) { + url.searchParams.set('q', q); + } + const resp = await fetch(url.href); if (!resp.ok) { throw new Error(`Failed to fetch apps: ${resp.status}`); } diff --git a/web/src/contexts/AppWindowsContext.tsx b/web/src/contexts/AppWindowsContext.tsx index 1f3e36d..d499fce 100644 --- a/web/src/contexts/AppWindowsContext.tsx +++ b/web/src/contexts/AppWindowsContext.tsx @@ -4,6 +4,7 @@ import type { VfsEntry } from '../api/client'; import type { AppDescriptor } from '../apps/registry'; import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry'; import { useI18n } from '../i18n'; +import { useNavigate } from 'react-router'; type WindowBase = { id: string; @@ -47,6 +48,7 @@ const AppWindowsContext = createContext(null); export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { t } = useI18n(); + const navigate = useNavigate(); const [windows, setWindows] = useState([]); const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor, currentPath: string) => { @@ -120,12 +122,27 @@ export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ ch const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => { const apps = getAppsForEntry(entry); if (!apps.length) { - Modal.error({ title: t('Cannot open file: no available app') }); + const ext = entry.name.split('.').pop()?.toLowerCase() || ''; + const extQuery = ext ? `.${ext}` : entry.name; + Modal.confirm({ + title: t('Cannot open file'), + content: t('No app available for this file. Go to App Store to search {ext}?', { ext: extQuery }), + okText: t('Go to App Store'), + cancelText: t('Cancel'), + onOk: () => { + const params = new URLSearchParams(); + params.set('tab', 'discover'); + if (extQuery) { + params.set('query', extQuery); + } + navigate(`/plugins?${params.toString()}`); + }, + }); return; } const defaultApp = getDefaultAppForEntry(entry) || apps[0]; openWithApp(entry, defaultApp, currentPath); - }, [openWithApp, t]); + }, [navigate, openWithApp, t]); const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string, currentPath: string) => { const app = getAppByKey(appKey); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 40a59f8..100595f 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -682,7 +682,10 @@ "Finish Initialization": "Finish Initialization", "Plugin run failed": "Plugin run failed", "Plugin Error": "Plugin Error", + "Cannot open file": "Cannot open file", "Cannot open file: no available app": "Cannot open file: no available app", + "No app available for this file. Go to App Store to search {ext}?": "No app available for this file. Go to App Store to search {ext}?", + "Go to App Store": "Go to App Store", "Error": "Error", "App \"{key}\" not found.": "App \"{key}\" not found.", "Open with {app}": "Open with {app}", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 5dd6e44..14cb2b1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -675,7 +675,10 @@ "Finish Initialization": "完成初始化", "Plugin run failed": "插件运行失败", "Plugin Error": "插件错误", + "Cannot open file": "无法打开该文件", "Cannot open file: no available app": "无法打开该文件:没有可用的应用", + "No app available for this file. Go to App Store to search {ext}?": "没有可用的应用。是否前往应用商店搜索 {ext} 可安装的应用?", + "Go to App Store": "去应用商店", "Error": "错误", "App \"{key}\" not found.": "应用 \"{key}\" 不存在。", "Open with {app}": "使用 {app} 打开", diff --git a/web/src/pages/PluginsPage.tsx b/web/src/pages/PluginsPage.tsx index 54b6935..d6411f8 100644 --- a/web/src/pages/PluginsPage.tsx +++ b/web/src/pages/PluginsPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, 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'; @@ -9,6 +9,7 @@ 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'; +import { useLocation } from 'react-router'; type InstallStatus = 'pending' | 'installing' | 'success' | 'failed' | 'skipped'; type InstallState = Partial<{ @@ -162,6 +163,9 @@ const PluginsPage = memo(function PluginsPage() { const { token } = theme.useToken(); const { t, lang } = useI18n(); const { openApp } = useAppWindows(); + const location = useLocation(); + const discoverySearchInitializedRef = useRef(false); + const discoverySearchTimerRef = useRef(null); const reload = useCallback(async () => { try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); } @@ -169,12 +173,12 @@ const PluginsPage = memo(function PluginsPage() { useEffect(() => { void reload(); }, [reload]); - // 加载应用发现列表 - const loadDiscoveryApps = useCallback(async () => { + // 加载应用发现列表(带搜索) + const loadDiscoveryApps = useCallback(async (query: string) => { try { setDiscoveryLoading(true); setDiscoveryError(undefined); - const apps = await fetchFoxelCoreApps(); + const apps = await fetchFoxelCoreApps(query); setDiscoveryApps(apps); } catch (e: any) { setDiscoveryError(e?.message || t('Failed to load apps')); @@ -184,10 +188,46 @@ const PluginsPage = memo(function PluginsPage() { }, [t]); useEffect(() => { - if (tab === 'discover' && discoveryApps.length === 0 && !discoveryError) { - void loadDiscoveryApps(); + const params = new URLSearchParams(location.search); + const tabParam = params.get('tab'); + const queryParam = (params.get('query') ?? params.get('q') ?? '').trim(); + + if (tabParam === 'installed' || tabParam === 'discover') { + setTab(tabParam); + } else if (queryParam) { + setTab('discover'); } - }, [discoveryApps.length, discoveryError, loadDiscoveryApps, tab]); + + setDiscoverySearch(queryParam); + }, [location.search]); + + useEffect(() => { + if (tab !== 'discover') { + discoverySearchInitializedRef.current = false; + if (discoverySearchTimerRef.current !== null) { + window.clearTimeout(discoverySearchTimerRef.current); + discoverySearchTimerRef.current = null; + } + return; + } + + const delay = discoverySearchInitializedRef.current ? 500 : 0; + if (discoverySearchTimerRef.current !== null) { + window.clearTimeout(discoverySearchTimerRef.current); + } + + discoverySearchTimerRef.current = window.setTimeout(() => { + discoverySearchInitializedRef.current = true; + void loadDiscoveryApps(discoverySearch); + }, delay); + + return () => { + if (discoverySearchTimerRef.current !== null) { + window.clearTimeout(discoverySearchTimerRef.current); + discoverySearchTimerRef.current = null; + } + }; + }, [discoverySearch, loadDiscoveryApps, tab]); const closeDetailModal = () => { setDetailOpen(false); @@ -383,25 +423,6 @@ const PluginsPage = memo(function PluginsPage() { )); }, [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); @@ -593,7 +614,7 @@ const PluginsPage = memo(function PluginsPage() { /> + - ) : filteredDiscoveryApps.length === 0 ? ( - + ) : discoveryApps.length === 0 ? ( + ) : (
- {filteredDiscoveryApps.map(app => { + {discoveryApps.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;