feat: add search functionality to fetchFoxelCoreApps and enhance PluginsPage with query handling

This commit is contained in:
shiyu
2026-01-06 21:18:26 +08:00
parent 4dd0a4b1d6
commit 4e16de973c
5 changed files with 84 additions and 36 deletions

View File

@@ -113,9 +113,13 @@ export async function fetchRepoList(params: RepoQueryParams = {}): Promise<RepoL
/**
* 从 foxel-core 应用中心获取应用列表
*/
export async function fetchFoxelCoreApps(): Promise<FoxelCoreApp[]> {
const url = `${FOXEL_CORE_BASE}/api/apps`;
const resp = await fetch(url);
export async function fetchFoxelCoreApps(query?: string): Promise<FoxelCoreApp[]> {
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}`);
}

View File

@@ -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<AppWindowsContextValue | null>(null);
export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { t } = useI18n();
const navigate = useNavigate();
const [windows, setWindows] = useState<AppWindowItem[]>([]);
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);

View File

@@ -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}",

View File

@@ -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} 打开",

View File

@@ -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<number | null>(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() {
/>
<Button
icon={<ReloadOutlined />}
onClick={loadDiscoveryApps}
onClick={() => { void loadDiscoveryApps(discoverySearch); }}
loading={discoveryLoading}
>
{t('Refresh')}
@@ -610,13 +631,13 @@ const PluginsPage = memo(function PluginsPage() {
</div>
) : discoveryError ? (
<Empty description={discoveryError}>
<Button onClick={loadDiscoveryApps}>{t('Refresh')}</Button>
<Button onClick={() => { void loadDiscoveryApps(discoverySearch); }}>{t('Refresh')}</Button>
</Empty>
) : filteredDiscoveryApps.length === 0 ? (
<Empty description={discoveryApps.length === 0 ? t('No results') : t('No results')} />
) : discoveryApps.length === 0 ? (
<Empty description={t('No results')} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, justifyContent: 'start' }}>
{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;