feat: enhance app descriptors with additional metadata and support for various file types

This commit is contained in:
shiyu
2025-12-12 18:09:44 +08:00
parent 1cda987723
commit 686202a0dd
11 changed files with 161 additions and 29 deletions

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "web",

View File

@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types';
import { ImageViewerApp } from './ImageViewer.tsx';
const supportedExts = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'];
export const descriptor: AppDescriptor = {
key: 'image-viewer',
name: '图片查看器',
iconUrl: 'https://api.iconify.design/mdi:image.svg',
description: '内置图片查看器,支持常见图片与部分 RAW 格式预览。',
author: 'Foxel',
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif', 'arw', 'cr2', 'cr3', 'nef', 'rw2', 'orf', 'pef', 'dng'].includes(ext);
return supportedExts.includes(ext);
},
component: ImageViewerApp,
default: true,

View File

@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types';
import { OfficeViewerApp } from './OfficeViewer.tsx';
const supportedExts = ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'];
export const descriptor: AppDescriptor = {
key: 'office-viewer',
name: 'Office 文档查看器',
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
description: '内置 Office 文档查看器,支持 Word/Excel/PowerPoint 文件预览。',
author: 'Foxel',
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'].includes(ext);
return supportedExts.includes(ext);
},
component: OfficeViewerApp,
default: true,

View File

@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types';
import { PdfViewerApp } from './PdfViewer';
const supportedExts = ['pdf'];
export const descriptor: AppDescriptor = {
key: 'pdf-viewer',
name: 'PDF 查看器',
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
description: '内置 PDF 查看器。',
author: 'Foxel',
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ext === 'pdf';
return supportedExts.includes(ext);
},
component: PdfViewerApp,
default: true,

View File

@@ -1,34 +1,39 @@
import type { AppDescriptor } from '../types';
import { TextEditorApp } from './TextEditor.tsx';
const supportedExts = [
// Text formats
'txt', 'md', 'markdown', 'log',
// Data formats
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'cfg', 'conf',
// Web technologies
'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'jsx', 'ts', 'tsx', 'vue',
// Programming languages
'py', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx',
'php', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'clj', 'cljs',
'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm',
// Database
'sql',
// Shell and scripts
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
// Build and config files
'dockerfile', 'makefile', 'gradle', 'cmake',
// Other common text files
'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'
];
export const descriptor: AppDescriptor = {
key: 'text-editor',
name: '文本编辑器',
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
description: '内置文本/代码编辑器,支持常见文本与代码格式。',
author: 'Foxel',
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
// Supports common text and code formats
return [
// Text formats
'txt', 'md', 'markdown', 'log',
// Data formats
'json', 'yaml', 'yml', 'xml', 'toml', 'ini', 'cfg', 'conf',
// Web technologies
'html', 'htm', 'css', 'scss', 'sass', 'less', 'js', 'jsx', 'ts', 'tsx', 'vue',
// Programming languages
'py', 'java', 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx',
'php', 'rb', 'go', 'rs', 'swift', 'kt', 'scala', 'clj', 'cljs',
'cs', 'vb', 'fs', 'pl', 'pm', 'r', 'lua', 'dart', 'elm',
// Database
'sql',
// Shell and scripts
'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
// Build and config files
'dockerfile', 'makefile', 'gradle', 'cmake',
// Other common text files
'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'
].includes(ext);
return supportedExts.includes(ext);
},
component: TextEditorApp,
default: true,

View File

@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types';
import { VideoPlayerApp } from './VideoPlayer.tsx';
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'];
export const descriptor: AppDescriptor = {
key: 'video-player',
name: '视频播放器',
iconUrl: 'https://api.iconify.design/mdi:video.svg',
description: '内置视频播放器,支持常见视频格式播放。',
author: 'Foxel',
supportedExts,
supported: (entry) => {
if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'].includes(ext);
return supportedExts.includes(ext);
},
component: VideoPlayerApp,
default: true,

View File

@@ -46,7 +46,15 @@ function registerPluginAsApp(p: PluginItem) {
});
}
loadApps();
const appsLoadedPromise = loadApps();
export async function ensureAppsLoaded() {
await appsLoadedPromise;
}
export function listSystemApps(): AppDescriptor[] {
return apps.filter(a => !a.key.startsWith('plugin:'));
}
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
return apps.filter(a => a.supported(entry));

View File

@@ -14,6 +14,11 @@ export interface AppDescriptor {
iconUrl?: string;
default?: boolean;
defaultMaximized?: boolean;
description?: string;
author?: string;
supportedExts?: string[];
website?: string;
github?: string;
/**
* 应用窗口的默认位置与尺寸(非最大化时生效)
* 任意字段缺省则按系统默认/级联偏移。

View File

@@ -601,6 +601,7 @@
"Please select a file": "Please select a file",
"Installed successfully": "Installed successfully",
"Plugin": "Plugin",
"System App": "System App",
"Open Link": "Open Link",
"Link copied": "Link copied",
"Copy Link": "Copy Link",
@@ -659,4 +660,4 @@
"Open with {app}": "Open with {app}",
"Set as default for .{ext}": "Set as default for .{ext}",
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON"
}
}

View File

@@ -594,6 +594,7 @@
"Please select a file": "请选择一个文件",
"Installed successfully": "安装成功",
"Plugin": "插件",
"System App": "系统应用",
"Open Link": "打开链接",
"Link copied": "已复制链接",
"Copy Link": "复制链接",
@@ -652,4 +653,4 @@
"Open with {app}": "使用 {app} 打开",
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON"
}
}

View File

@@ -3,12 +3,13 @@ import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm,
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 { reloadPluginApps, ensureAppsLoaded, listSystemApps, type AppDescriptor } 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 [systemApps, setSystemApps] = useState<AppDescriptor[]>([]);
const [adding, setAdding] = useState(false);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState('');
@@ -30,6 +31,14 @@ const PluginsPage = memo(function PluginsPage() {
};
useEffect(() => { reload(); }, []);
useEffect(() => {
(async () => {
try {
await ensureAppsLoaded();
setSystemApps(listSystemApps());
} catch {}
})();
}, []);
const installedKeySet = useMemo(() => {
const set = new Set<string>();
@@ -83,6 +92,20 @@ const PluginsPage = memo(function PluginsPage() {
));
}, [data, q]);
const filteredSystemApps = useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return systemApps;
return systemApps.filter(a => (
(a.name || '').toLowerCase().includes(s)
|| (a.author || '').toLowerCase().includes(s)
|| (a.website || '').toLowerCase().includes(s)
|| (a.github || '').toLowerCase().includes(s)
|| (a.description || '').toLowerCase().includes(s)
|| (a.supportedExts || []).some(e => e.toLowerCase().includes(s))
|| (a.key || '').toLowerCase().includes(s)
));
}, [systemApps, q]);
const renderCard = (p: PluginItem) => {
const icon = p.icon || '/plugins/demo-text-viewer.svg';
const name = p.name || `${t('Plugin')} ${p.id}`;
@@ -149,6 +172,73 @@ const PluginsPage = memo(function PluginsPage() {
);
};
const renderSystemCard = (a: AppDescriptor) => {
const icon = a.iconUrl || '/plugins/demo-text-viewer.svg';
const name = a.name || a.key;
const exts = (a.supportedExts || []).slice(0, 6);
const more = (a.supportedExts || []).length - exts.length;
const link = a.website || a.github || '';
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>
<Tag style={{ marginLeft: 'auto' }}>{t('System App')}</Tag>
</div>
);
return (
<Card
key={`system:${a.key}`}
title={title}
hoverable
size="small"
styles={{ body: { padding: 12 } } as any}
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
actions={[
<Button
key="open"
type="link"
size="small"
disabled={!link}
onClick={() => { if (link) window.open(link, '_blank', 'noreferrer'); }}
>
{t('Open Link')}
</Button>,
<Button
key="copy"
type="link"
size="small"
disabled={!link}
onClick={async () => {
if (!link) return;
try { await navigator.clipboard.writeText(link); message.success(t('Link copied')); } catch {}
}}
>
{t('Copy Link')}
</Button>,
<Button key="del" type="link" danger size="small" disabled>{t('Delete')}</Button>
]}
>
<Typography.Paragraph
style={{ marginBottom: 8, minHeight: 44, lineHeight: '22px' }}
ellipsis={{ rows: 2 }}
>
{a.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' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
<span>{t('Author')}: {a.author || 'Foxel'}</span>
<span style={{ marginLeft: 'auto', color: token.colorTextTertiary }}>{t('System App')}</span>
</div>
</Card>
);
};
const renderRepoCard = (item: RepoItem) => {
const icon = item.icon || '/plugins/demo-text-viewer.svg';
const name = item.name || item.key;
@@ -277,10 +367,11 @@ const PluginsPage = memo(function PluginsPage() {
</Card>
))}
</div>
) : filtered.length === 0 ? (
) : (filteredSystemApps.length + filtered.length) === 0 ? (
<Empty description={t('No plugins')} />
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
{filteredSystemApps.map(renderSystemCard)}
{filtered.map(renderCard)}
</div>
)}