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
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "web", "name": "web",
+6 -1
View File
@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types'; import type { AppDescriptor } from '../types';
import { ImageViewerApp } from './ImageViewer.tsx'; 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 = { export const descriptor: AppDescriptor = {
key: 'image-viewer', key: 'image-viewer',
name: '图片查看器', name: '图片查看器',
iconUrl: 'https://api.iconify.design/mdi:image.svg', iconUrl: 'https://api.iconify.design/mdi:image.svg',
description: '内置图片查看器,支持常见图片与部分 RAW 格式预览。',
author: 'Foxel',
supportedExts,
supported: (entry) => { supported: (entry) => {
if (entry.is_dir) return false; if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || ''; 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, component: ImageViewerApp,
default: true, default: true,
+6 -1
View File
@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types'; import type { AppDescriptor } from '../types';
import { OfficeViewerApp } from './OfficeViewer.tsx'; import { OfficeViewerApp } from './OfficeViewer.tsx';
const supportedExts = ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'];
export const descriptor: AppDescriptor = { export const descriptor: AppDescriptor = {
key: 'office-viewer', key: 'office-viewer',
name: 'Office 文档查看器', name: 'Office 文档查看器',
iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg', iconUrl: 'https://api.iconify.design/mdi:file-word-box.svg',
description: '内置 Office 文档查看器,支持 Word/Excel/PowerPoint 文件预览。',
author: 'Foxel',
supportedExts,
supported: (entry) => { supported: (entry) => {
if (entry.is_dir) return false; if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || ''; const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'].includes(ext); return supportedExts.includes(ext);
}, },
component: OfficeViewerApp, component: OfficeViewerApp,
default: true, default: true,
+6 -1
View File
@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types'; import type { AppDescriptor } from '../types';
import { PdfViewerApp } from './PdfViewer'; import { PdfViewerApp } from './PdfViewer';
const supportedExts = ['pdf'];
export const descriptor: AppDescriptor = { export const descriptor: AppDescriptor = {
key: 'pdf-viewer', key: 'pdf-viewer',
name: 'PDF 查看器', name: 'PDF 查看器',
iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg', iconUrl: 'https://api.iconify.design/mdi:file-pdf-box.svg',
description: '内置 PDF 查看器。',
author: 'Foxel',
supportedExts,
supported: (entry) => { supported: (entry) => {
if (entry.is_dir) return false; if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || ''; const ext = entry.name.split('.').pop()?.toLowerCase() || '';
return ext === 'pdf'; return supportedExts.includes(ext);
}, },
component: PdfViewerApp, component: PdfViewerApp,
default: true, default: true,
+25 -20
View File
@@ -1,34 +1,39 @@
import type { AppDescriptor } from '../types'; import type { AppDescriptor } from '../types';
import { TextEditorApp } from './TextEditor.tsx'; 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 = { export const descriptor: AppDescriptor = {
key: 'text-editor', key: 'text-editor',
name: '文本编辑器', name: '文本编辑器',
iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg', iconUrl: 'https://api.iconify.design/mdi:file-document-outline.svg',
description: '内置文本/代码编辑器,支持常见文本与代码格式。',
author: 'Foxel',
supportedExts,
supported: (entry) => { supported: (entry) => {
if (entry.is_dir) return false; if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || ''; const ext = entry.name.split('.').pop()?.toLowerCase() || '';
// Supports common text and code formats // Supports common text and code formats
return [ return supportedExts.includes(ext);
// 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);
}, },
component: TextEditorApp, component: TextEditorApp,
default: true, default: true,
+6 -1
View File
@@ -1,14 +1,19 @@
import type { AppDescriptor } from '../types'; import type { AppDescriptor } from '../types';
import { VideoPlayerApp } from './VideoPlayer.tsx'; import { VideoPlayerApp } from './VideoPlayer.tsx';
const supportedExts = ['mp4','webm','ogg','m4v','mov','mkv','avi','wmv','flv','3gp'];
export const descriptor: AppDescriptor = { export const descriptor: AppDescriptor = {
key: 'video-player', key: 'video-player',
name: '视频播放器', name: '视频播放器',
iconUrl: 'https://api.iconify.design/mdi:video.svg', iconUrl: 'https://api.iconify.design/mdi:video.svg',
description: '内置视频播放器,支持常见视频格式播放。',
author: 'Foxel',
supportedExts,
supported: (entry) => { supported: (entry) => {
if (entry.is_dir) return false; if (entry.is_dir) return false;
const ext = entry.name.split('.').pop()?.toLowerCase() || ''; 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, component: VideoPlayerApp,
default: true, default: true,
+9 -1
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[] { export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
return apps.filter(a => a.supported(entry)); return apps.filter(a => a.supported(entry));
+5
View File
@@ -14,6 +14,11 @@ export interface AppDescriptor {
iconUrl?: string; iconUrl?: string;
default?: boolean; default?: boolean;
defaultMaximized?: boolean; defaultMaximized?: boolean;
description?: string;
author?: string;
supportedExts?: string[];
website?: string;
github?: string;
/** /**
* 应用窗口的默认位置与尺寸(非最大化时生效) * 应用窗口的默认位置与尺寸(非最大化时生效)
* 任意字段缺省则按系统默认/级联偏移。 * 任意字段缺省则按系统默认/级联偏移。
+2 -1
View File
@@ -601,6 +601,7 @@
"Please select a file": "Please select a file", "Please select a file": "Please select a file",
"Installed successfully": "Installed successfully", "Installed successfully": "Installed successfully",
"Plugin": "Plugin", "Plugin": "Plugin",
"System App": "System App",
"Open Link": "Open Link", "Open Link": "Open Link",
"Link copied": "Link copied", "Link copied": "Link copied",
"Copy Link": "Copy Link", "Copy Link": "Copy Link",
@@ -659,4 +660,4 @@
"Open with {app}": "Open with {app}", "Open with {app}": "Open with {app}",
"Set as default for .{ext}": "Set as default for .{ext}", "Set as default for .{ext}": "Set as default for .{ext}",
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON" "Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON"
} }
+2 -1
View File
@@ -594,6 +594,7 @@
"Please select a file": "请选择一个文件", "Please select a file": "请选择一个文件",
"Installed successfully": "安装成功", "Installed successfully": "安装成功",
"Plugin": "插件", "Plugin": "插件",
"System App": "系统应用",
"Open Link": "打开链接", "Open Link": "打开链接",
"Link copied": "已复制链接", "Link copied": "已复制链接",
"Copy Link": "复制链接", "Copy Link": "复制链接",
@@ -652,4 +653,4 @@
"Open with {app}": "使用 {app} 打开", "Open with {app}": "使用 {app} 打开",
"Set as default for .{ext}": "设为该类型(.{ext})默认应用", "Set as default for .{ext}": "设为该类型(.{ext})默认应用",
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON" "Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON"
} }
+93 -2
View File
@@ -3,12 +3,13 @@ import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm,
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, ensureAppsLoaded, listSystemApps, type AppDescriptor } from '../apps/registry';
import { useI18n } from '../i18n'; import { useI18n } from '../i18n';
import { fetchRepoList, type RepoItem, buildCenterUrl } from '../api/pluginCenter'; 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 [systemApps, setSystemApps] = useState<AppDescriptor[]>([]);
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('');
@@ -30,6 +31,14 @@ const PluginsPage = memo(function PluginsPage() {
}; };
useEffect(() => { reload(); }, []); useEffect(() => { reload(); }, []);
useEffect(() => {
(async () => {
try {
await ensureAppsLoaded();
setSystemApps(listSystemApps());
} catch {}
})();
}, []);
const installedKeySet = useMemo(() => { const installedKeySet = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -83,6 +92,20 @@ const PluginsPage = memo(function PluginsPage() {
)); ));
}, [data, q]); }, [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 renderCard = (p: PluginItem) => {
const icon = p.icon || '/plugins/demo-text-viewer.svg'; const icon = p.icon || '/plugins/demo-text-viewer.svg';
const name = p.name || `${t('Plugin')} ${p.id}`; 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 renderRepoCard = (item: RepoItem) => {
const icon = item.icon || '/plugins/demo-text-viewer.svg'; const icon = item.icon || '/plugins/demo-text-viewer.svg';
const name = item.name || item.key; const name = item.name || item.key;
@@ -277,10 +367,11 @@ const PluginsPage = memo(function PluginsPage() {
</Card> </Card>
))} ))}
</div> </div>
) : filtered.length === 0 ? ( ) : (filteredSystemApps.length + filtered.length) === 0 ? (
<Empty description={t('No plugins')} /> <Empty description={t('No plugins')} />
) : ( ) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
{filteredSystemApps.map(renderSystemCard)}
{filtered.map(renderCard)} {filtered.map(renderCard)}
</div> </div>
)} )}