mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 04:42:50 +08:00
feat: Add App Center plugin functionality
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,4 +6,5 @@ __pycache__/
|
||||
.vscode/
|
||||
data/
|
||||
migrate/
|
||||
.env
|
||||
.env
|
||||
AGENTS.md
|
||||
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .routes import adapters, virtual_fs, auth, config, processors, tasks, logs, share, backup, search, vector_db
|
||||
from .routes import plugins
|
||||
|
||||
|
||||
def include_routers(app: FastAPI):
|
||||
@@ -15,4 +16,5 @@ def include_routers(app: FastAPI):
|
||||
app.include_router(share.router)
|
||||
app.include_router(share.public_router)
|
||||
app.include_router(backup.router)
|
||||
app.include_router(vector_db.router)
|
||||
app.include_router(vector_db.router)
|
||||
app.include_router(plugins.router)
|
||||
73
api/routes/plugins.py
Normal file
73
api/routes/plugins.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from typing import List, Any, Dict
|
||||
from fastapi import APIRouter, HTTPException, Body
|
||||
from models import database
|
||||
from schemas import PluginCreate, PluginOut
|
||||
|
||||
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
|
||||
|
||||
|
||||
@router.post("", response_model=PluginOut)
|
||||
async def create_plugin(payload: PluginCreate):
|
||||
rec = await database.Plugin.create(
|
||||
url=payload.url,
|
||||
enabled=payload.enabled,
|
||||
)
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
|
||||
@router.get("", response_model=List[PluginOut])
|
||||
async def list_plugins():
|
||||
rows = await database.Plugin.all().order_by("-id")
|
||||
return [PluginOut.model_validate(r) for r in rows]
|
||||
|
||||
|
||||
@router.delete("/{plugin_id}")
|
||||
async def delete_plugin(plugin_id: int):
|
||||
rec = await database.Plugin.get_or_none(id=plugin_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
await rec.delete()
|
||||
return {"code": 0, "msg": "ok"}
|
||||
|
||||
|
||||
@router.put("/{plugin_id}", response_model=PluginOut)
|
||||
async def update_plugin(plugin_id: int, payload: PluginCreate):
|
||||
rec = await database.Plugin.get_or_none(id=plugin_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
rec.url = payload.url
|
||||
rec.enabled = payload.enabled
|
||||
await rec.save()
|
||||
return PluginOut.model_validate(rec)
|
||||
|
||||
|
||||
@router.post("/{plugin_id}/metadata", response_model=PluginOut)
|
||||
async def update_manifest(plugin_id: int, manifest: Dict[str, Any] = Body(...)):
|
||||
rec = await database.Plugin.get_or_none(id=plugin_id)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail="Plugin not found")
|
||||
key_map = {
|
||||
'key': 'key',
|
||||
'name': 'name',
|
||||
'version': 'version',
|
||||
'supported_exts': 'supported_exts',
|
||||
'supportedExts': 'supported_exts',
|
||||
'default_bounds': 'default_bounds',
|
||||
'defaultBounds': 'default_bounds',
|
||||
'default_maximized': 'default_maximized',
|
||||
'defaultMaximized': 'default_maximized',
|
||||
'icon': 'icon',
|
||||
'description': 'description',
|
||||
'author': 'author',
|
||||
'website': 'website',
|
||||
'github': 'github',
|
||||
}
|
||||
for k, v in list(manifest.items()):
|
||||
if v is None:
|
||||
continue
|
||||
attr = key_map.get(k)
|
||||
if not attr:
|
||||
continue
|
||||
setattr(rec, attr, v)
|
||||
await rec.save()
|
||||
return PluginOut.model_validate(rec)
|
||||
@@ -81,3 +81,29 @@ class ShareLink(Model):
|
||||
|
||||
class Meta:
|
||||
table = "share_links"
|
||||
|
||||
|
||||
class Plugin(Model):
|
||||
id = fields.IntField(pk=True)
|
||||
url = fields.CharField(max_length=2048)
|
||||
enabled = fields.BooleanField(default=True)
|
||||
|
||||
key = fields.CharField(max_length=100, null=True)
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
version = fields.CharField(max_length=50, null=True)
|
||||
supported_exts = fields.JSONField(null=True)
|
||||
|
||||
default_bounds = fields.JSONField(null=True)
|
||||
default_maximized = fields.BooleanField(null=True)
|
||||
|
||||
icon = fields.CharField(max_length=2048, null=True)
|
||||
description = fields.TextField(null=True)
|
||||
author = fields.CharField(max_length=255, null=True)
|
||||
website = fields.CharField(max_length=2048, null=True)
|
||||
github = fields.CharField(max_length=2048, null=True)
|
||||
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "plugins"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from schemas.plugins import PluginCreate,PluginOut
|
||||
from .adapters import AdapterCreate, AdapterOut
|
||||
from .fs import MkdirRequest, MoveRequest
|
||||
|
||||
__all__ = [
|
||||
"PluginOut"
|
||||
"PluginCreate"
|
||||
"AdapterCreate",
|
||||
"AdapterOut",
|
||||
"MkdirRequest",
|
||||
|
||||
27
schemas/plugins.py
Normal file
27
schemas/plugins.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PluginCreate(BaseModel):
|
||||
url: str = Field(min_length=1)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PluginOut(BaseModel):
|
||||
id: int
|
||||
url: str
|
||||
enabled: bool
|
||||
key: Optional[str]
|
||||
name: Optional[str]
|
||||
version: Optional[str]
|
||||
supported_exts: Optional[List[str]]
|
||||
default_bounds: Optional[Dict[str, Any]]
|
||||
default_maximized: Optional[bool]
|
||||
icon: Optional[str]
|
||||
description: Optional[str]
|
||||
author: Optional[str]
|
||||
website: Optional[str]
|
||||
github: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
46
web/src/api/plugins.ts
Normal file
46
web/src/api/plugins.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import request from './client';
|
||||
|
||||
export interface PluginItem {
|
||||
id: number;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
key?: string | null;
|
||||
name?: string | null;
|
||||
version?: string | null;
|
||||
supported_exts?: string[] | null;
|
||||
default_bounds?: Record<string, any> | null;
|
||||
default_maximized?: boolean | null;
|
||||
icon?: string | null;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
website?: string | null;
|
||||
github?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginCreate {
|
||||
url: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginManifestUpdate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
supported_exts?: string[];
|
||||
default_bounds?: Record<string, any>;
|
||||
default_maximized?: boolean;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
list: () => request<PluginItem[]>(`/plugins`),
|
||||
create: (payload: PluginCreate) => request<PluginItem>(`/plugins`, { method: 'POST', json: payload }),
|
||||
remove: (id: number) => request(`/plugins/${id}`, { method: 'DELETE' }),
|
||||
update: (id: number, payload: PluginCreate) => request<PluginItem>(`/plugins/${id}`, { method: 'PUT', json: payload }),
|
||||
updateManifest: (id: number, payload: PluginManifestUpdate) => request<PluginItem>(`/plugins/${id}/metadata`, { method: 'POST', json: payload }),
|
||||
};
|
||||
|
||||
57
web/src/apps/PluginHost/index.tsx
Normal file
57
web/src/apps/PluginHost/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
import { loadPluginFromUrl, ensureManifest, type RegisteredPlugin } from '../../plugins/runtime';
|
||||
import type { PluginItem } from '../../api/plugins';
|
||||
import { useAsyncSafeEffect } from '../../hooks/useAsyncSafeEffect';
|
||||
|
||||
export interface PluginAppHostProps extends AppComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({ plugin, filePath, entry, onRequestClose }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
|
||||
const pluginRef = useRef<RegisteredPlugin | null>(null);
|
||||
|
||||
useAsyncSafeEffect(
|
||||
async ({ isDisposed }) => {
|
||||
try {
|
||||
const p = await loadPluginFromUrl(plugin.url);
|
||||
if (isDisposed()) return;
|
||||
pluginRef.current = p;
|
||||
await ensureManifest(plugin.id, p);
|
||||
if (isDisposed()) return;
|
||||
const token = await vfsApi.getTempLinkToken(filePath);
|
||||
if (isDisposed()) return;
|
||||
const downloadUrl = vfsApi.getTempPublicUrl(token.token);
|
||||
if (isDisposed() || !containerRef.current) return;
|
||||
await p.mount(containerRef.current, {
|
||||
filePath,
|
||||
entry,
|
||||
urls: { downloadUrl },
|
||||
host: { close: () => onCloseRef.current() },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!isDisposed()) setError(e?.message || '插件运行失败');
|
||||
}
|
||||
},
|
||||
[plugin.id, plugin.url, filePath],
|
||||
() => {
|
||||
try {
|
||||
if (pluginRef.current?.unmount && containerRef.current) {
|
||||
pluginRef.current.unmount(containerRef.current);
|
||||
}
|
||||
} catch {}
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <div style={{ padding: 12, color: 'red' }}>插件错误: {error}</div>;
|
||||
}
|
||||
|
||||
return <div ref={containerRef} style={{ width: '100%', height: '100%', overflow: 'auto' }} />;
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from './types';
|
||||
import React from 'react';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { PluginAppHost } from './PluginHost';
|
||||
const apps: AppDescriptor[] = [];
|
||||
|
||||
// 使用 import.meta.glob 动态导入所有应用
|
||||
// vite-glob-ignore
|
||||
const appModules = import.meta.glob('./*/index.ts');
|
||||
|
||||
async function loadApps() {
|
||||
@@ -16,11 +18,34 @@ async function loadApps() {
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
items.filter(p => p.enabled !== false).forEach((p) => registerPluginAsApp(p));
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
// 立即加载并注册所有应用
|
||||
loadApps();
|
||||
function registerPluginAsApp(p: PluginItem) {
|
||||
const key = 'plugin:' + p.id;
|
||||
if (apps.find(a => a.key === key)) return;
|
||||
const supported = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!p.supported_exts || p.supported_exts.length === 0) return true;
|
||||
return p.supported_exts.includes(ext);
|
||||
};
|
||||
apps.push({
|
||||
key,
|
||||
name: p.name || `插件 ${p.id}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
defaultMaximized: p.default_maximized || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
loadApps();
|
||||
|
||||
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
|
||||
return apps.filter(a => a.supported(entry));
|
||||
@@ -43,3 +68,27 @@ export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefine
|
||||
|
||||
export type { AppDescriptor };
|
||||
export type { AppComponentProps } from './types';
|
||||
|
||||
export async function reloadPluginApps() {
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
const keepKeys = new Set(items.filter(p => p.enabled !== false).map(p => 'plugin:' + p.id));
|
||||
for (let i = apps.length - 1; i >= 0; i--) {
|
||||
const a = apps[i];
|
||||
if (a.key.startsWith('plugin:') && !keepKeys.has(a.key)) {
|
||||
apps.splice(i, 1);
|
||||
}
|
||||
}
|
||||
items.filter(p => p.enabled !== false).forEach(p => {
|
||||
const key = 'plugin:' + p.id;
|
||||
const existing = apps.find(a => a.key === key);
|
||||
if (!existing) {
|
||||
registerPluginAsApp(p);
|
||||
} else {
|
||||
existing.name = p.name || `插件 ${p.id}`;
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
}
|
||||
|
||||
35
web/src/hooks/useAsyncSafeEffect.ts
Normal file
35
web/src/hooks/useAsyncSafeEffect.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export interface AsyncEffectCtx {
|
||||
isDisposed: () => boolean;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function useAsyncSafeEffect(
|
||||
effect: (ctx: AsyncEffectCtx) => void | Promise<void>,
|
||||
deps: React.DependencyList,
|
||||
cleanup?: (ctx: AsyncEffectCtx) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const ac = new AbortController();
|
||||
const ctx: AsyncEffectCtx = {
|
||||
isDisposed: () => disposed,
|
||||
signal: ac.signal,
|
||||
};
|
||||
|
||||
Promise.resolve(effect(ctx)).catch(() => {
|
||||
// 故意忽略 effect 内部抛出的异常,交由调用方处理
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
cleanup?.(ctx);
|
||||
} finally {
|
||||
ac.abort();
|
||||
}
|
||||
};
|
||||
}, deps);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
RobotOutlined,
|
||||
BugOutlined,
|
||||
DatabaseOutlined,
|
||||
AppstoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
@@ -30,6 +31,7 @@ export const navGroups: NavGroup[] = [
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: '我的分享' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: '离线下载' },
|
||||
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: '存储挂载' },
|
||||
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: '应用中心' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
157
web/src/pages/PluginsPage.tsx
Normal file
157
web/src/pages/PluginsPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Form, Input, Tag, message, Card, Typography, Popconfirm, Empty, Skeleton, theme, Divider } 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';
|
||||
|
||||
const PluginsPage = memo(function PluginsPage() {
|
||||
const [data, setData] = useState<PluginItem[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [form] = Form.useForm<{ url: string }>();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const reload = async () => {
|
||||
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { reload(); }, []);
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
const { url } = await form.validateFields();
|
||||
const created = await pluginsApi.create({ url });
|
||||
try {
|
||||
const p = await loadPluginFromUrl(created.url);
|
||||
await ensureManifest(created.id, p);
|
||||
} catch {}
|
||||
setAdding(false);
|
||||
form.resetFields();
|
||||
await reload();
|
||||
await reloadPluginApps();
|
||||
message.success('安装成功');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
if (!s) return data;
|
||||
return data.filter(p => (
|
||||
(p.name || '').toLowerCase().includes(s)
|
||||
|| (p.author || '').toLowerCase().includes(s)
|
||||
|| (p.url || '').toLowerCase().includes(s)
|
||||
|| (p.description || '').toLowerCase().includes(s)
|
||||
|| (p.supported_exts || []).some(e => e.toLowerCase().includes(s))
|
||||
));
|
||||
}, [data, q]);
|
||||
|
||||
const renderCard = (p: PluginItem) => {
|
||||
const icon = p.icon || '/plugins/demo-text-viewer.svg';
|
||||
const name = p.name || `插件 ${p.id}`;
|
||||
const exts = (p.supported_exts || []).slice(0, 6);
|
||||
const more = (p.supported_exts || []).length - exts.length;
|
||||
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>
|
||||
{p.version && <Tag color="blue" style={{ marginLeft: 'auto' }}>{p.version}</Tag>}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
key={p.id}
|
||||
title={title}
|
||||
hoverable
|
||||
size="small"
|
||||
styles={{ body: { padding: 12 } } as any}
|
||||
style={{ borderRadius: 10, boxShadow: token.boxShadowTertiary }}
|
||||
actions={[
|
||||
<a key="open" href={p.url} target="_blank" rel="noreferrer">打开链接</a>,
|
||||
<Button key="copy" type="link" size="small" onClick={async () => { try { await navigator.clipboard.writeText(p.url); message.success('已复制链接'); } catch {} }}>复制链接</Button>,
|
||||
<Popconfirm key="del" title="确认删除该插件?" onConfirm={async () => { await pluginsApi.remove(p.id); await reload(); await reloadPluginApps(); }}>
|
||||
<Button type="link" danger size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
]}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Paragraph style={{ marginBottom: 8 }} ellipsis={{ rows: 2 }}>
|
||||
{p.description || '(暂无描述)'}
|
||||
</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
{(exts.length > 0 ? exts : ['任意']).map(e => <Tag key={e}>{e}</Tag>)}
|
||||
{more > 0 && <Tag>+{more}</Tag>}
|
||||
</div>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{(p.author || p.github || p.website) && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{p.author && <span>作者: {p.author}</span>}
|
||||
<span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
{p.github && (
|
||||
<a href={p.github || undefined} target="_blank" rel="noreferrer" title="GitHub">
|
||||
<GithubOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
{p.website && (
|
||||
<a href={p.website || undefined} target="_blank" rel="noreferrer" title="官网">
|
||||
<LinkOutlined style={{ fontSize: 16, color: token.colorTextTertiary }} />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Button type="primary" onClick={() => setAdding(true)}>安装应用</Button>
|
||||
<Button onClick={reload} loading={loading}>刷新</Button>
|
||||
<Input
|
||||
placeholder="搜索 名称/作者/链接/扩展名"
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
allowClear
|
||||
style={{ maxWidth: 320, marginLeft: 'auto' }}
|
||||
onPressEnter={() => reload()}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12 }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} style={{ borderRadius: 10 }}>
|
||||
<Skeleton active avatar paragraph={{ rows: 3 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty description="暂无插件" />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 12 }}>
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
<Modal
|
||||
title="安装应用"
|
||||
open={adding}
|
||||
onCancel={() => setAdding(false)}
|
||||
onOk={handleAdd}
|
||||
okText="安装"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="url" label="应用链接" rules={[{ required: true }, { type: 'url', message: '请输入合法的 URL' }]}>
|
||||
<Input placeholder="https://example.com/plugin.js" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PluginsPage;
|
||||
120
web/src/plugins/runtime.ts
Normal file
120
web/src/plugins/runtime.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { pluginsApi, type PluginManifestUpdate } from '../api/plugins';
|
||||
|
||||
export interface RegisteredPlugin {
|
||||
mount: (container: HTMLElement, ctx: {
|
||||
filePath: string;
|
||||
entry: any;
|
||||
urls: { downloadUrl: string };
|
||||
host: HostApi;
|
||||
}) => void | Promise<void>;
|
||||
unmount?: (container: HTMLElement) => void | Promise<void>;
|
||||
|
||||
key?: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
supportedExts?: string[];
|
||||
defaultBounds?: { x?: number; y?: number; width?: number; height?: number };
|
||||
defaultMaximized?: boolean;
|
||||
icon?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
}
|
||||
|
||||
export interface HostApi {
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const loadedPlugins = new Map<string, RegisteredPlugin>();
|
||||
const waiters = new Map<string, ((p: RegisteredPlugin) => void)[]>();
|
||||
const injected = new Set<string>();
|
||||
|
||||
declare global {
|
||||
interface Window { FoxelRegister?: (plugin: RegisteredPlugin) => void; }
|
||||
}
|
||||
|
||||
window.FoxelRegister = (plugin: RegisteredPlugin) => {
|
||||
const pendingUrl = sessionStorage.getItem('foxel:pendingPluginUrl') || '';
|
||||
if (pendingUrl) {
|
||||
loadedPlugins.set(pendingUrl, plugin);
|
||||
const resolvers = waiters.get(pendingUrl) || [];
|
||||
resolvers.forEach(fn => fn(plugin));
|
||||
waiters.delete(pendingUrl);
|
||||
sessionStorage.removeItem('foxel:pendingPluginUrl');
|
||||
} else {
|
||||
const anyUrl = Array.from(waiters.keys())[0];
|
||||
if (anyUrl) {
|
||||
loadedPlugins.set(anyUrl, plugin);
|
||||
const resolvers = waiters.get(anyUrl) || [];
|
||||
resolvers.forEach(fn => fn(plugin));
|
||||
waiters.delete(anyUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function loadPluginFromUrl(url: string): Promise<RegisteredPlugin> {
|
||||
const existing = loadedPlugins.get(url);
|
||||
if (existing) return existing;
|
||||
return new Promise<RegisteredPlugin>((resolve, reject) => {
|
||||
const arr = waiters.get(url) || [];
|
||||
arr.push(resolve);
|
||||
waiters.set(url, arr);
|
||||
|
||||
const ready = loadedPlugins.get(url);
|
||||
if (ready) {
|
||||
const resolvers = waiters.get(url) || [];
|
||||
resolvers.forEach(fn => fn(ready));
|
||||
waiters.delete(url);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem('foxel:pendingPluginUrl', url);
|
||||
|
||||
if (!injected.has(url)) {
|
||||
injected.add(url);
|
||||
const script = document.createElement('script');
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.onerror = () => {
|
||||
waiters.delete(url);
|
||||
reject(new Error('Failed to load plugin script: ' + url));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
const t = setTimeout(() => {
|
||||
if (!loadedPlugins.get(url)) {
|
||||
waiters.delete(url);
|
||||
reject(new Error('Plugin did not call FoxelRegister: ' + url));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
const last = arr[arr.length - 1];
|
||||
arr[arr.length - 1] = (p: RegisteredPlugin) => { clearTimeout(t); last(p); };
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureManifest(pluginId: number, plugin: RegisteredPlugin) {
|
||||
const manifest: PluginManifestUpdate = {
|
||||
key: plugin.key,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
supported_exts: plugin.supportedExts,
|
||||
default_bounds: plugin.defaultBounds,
|
||||
default_maximized: plugin.defaultMaximized,
|
||||
icon: plugin.icon,
|
||||
description: plugin.description,
|
||||
author: plugin.author,
|
||||
website: plugin.website,
|
||||
github: plugin.github,
|
||||
};
|
||||
try { console.debug('[foxel] report manifest', pluginId, manifest); } catch { }
|
||||
const key = `foxel:manifestReported:${pluginId}`;
|
||||
if (sessionStorage.getItem(key) === '1') return;
|
||||
try {
|
||||
await pluginsApi.updateManifest(pluginId, manifest);
|
||||
sessionStorage.setItem(key, '1');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
|
||||
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
|
||||
import LogsPage from '../pages/LogsPage.tsx';
|
||||
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||
import PluginsPage from '../pages/PluginsPage.tsx';
|
||||
|
||||
const LayoutShell = memo(function LayoutShell() {
|
||||
const { navKey = 'files' } = useParams();
|
||||
@@ -34,10 +35,10 @@ const LayoutShell = memo(function LayoutShell() {
|
||||
{navKey === 'share' && <SharePage />}
|
||||
{navKey === 'tasks' && <TasksPage />}
|
||||
{navKey === 'offline' && <OfflineDownloadPage />}
|
||||
{navKey === 'plugins' && <PluginsPage />}
|
||||
{navKey === 'settings' && <SystemSettingsPage />}
|
||||
{navKey === 'logs' && <LogsPage />}
|
||||
{navKey === 'backup' && <BackupPage />}
|
||||
{!['adapters','files','image','video','doc','fav','recent','recycle','share','tasks','offline','settings', 'logs', 'backup'].includes(navKey!) && <FileExplorerPage />}
|
||||
</Flex>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
|
||||
Reference in New Issue
Block a user