feat(plugin): add local plugin discovery and management with cache busting

This commit is contained in:
HouYunFei
2026-07-15 12:55:59 +08:00
parent eef4d96787
commit 88510223eb
5 changed files with 92 additions and 12 deletions
@@ -80,6 +80,11 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{record.version}
</span>
{record.local && (
<span className="rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: "#22c55e22", color: "#16a34a" }}>
</span>
)}
</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}>
{record.description || record.url}
@@ -91,10 +96,14 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
loading={busyId === record.id}
onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")}
/>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
</>
)}
</div>
))
)}
+41 -3
View File
@@ -50,6 +50,11 @@ async function fetchPluginSource(url: string) {
return response.text();
}
// 加缓存穿透参数,配合 watch 构建拿到最新产物
function withCacheBust(url: string) {
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
}
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
export async function installPluginFromUrl(url: string) {
const source = await fetchPluginSource(url);
@@ -70,7 +75,9 @@ export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean
deactivatePlugin(record.id);
return;
}
const plugin = await evaluatePluginSource(record.source);
// 本地插件启用时按 url 重新拉取,拿到最新构建(缓存 source 可能已过期)
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
const plugin = await evaluatePluginSource(source);
activatePlugin(plugin);
}
@@ -86,11 +93,14 @@ export async function ensurePluginsLoaded() {
if (loaded) return;
loaded = true;
await usePluginStore.persist.rehydrate();
await loadLocalPlugins(); // 先发现本地插件(默认关闭),再统一按 enabled 激活
const records = usePluginStore.getState().plugins.filter((record) => record.enabled);
await Promise.all(
records.map(async (record) => {
try {
activatePlugin(await evaluatePluginSource(record.source));
// 本地插件用最新产物,其余用缓存的源码
const source = record.local ? await fetchPluginSource(withCacheBust(record.url)) : record.source;
activatePlugin(await evaluatePluginSource(source));
} catch (error) {
console.error(`[plugin] 加载失败: ${record.id}`, error);
}
@@ -99,6 +109,34 @@ export async function ensurePluginsLoaded() {
await loadDevPlugins();
}
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
async function loadLocalPlugins() {
let urls: unknown;
try {
const response = await fetch("/plugins/index.json");
if (!response.ok) return;
urls = await response.json();
} catch {
return; // 无本地清单(如生产环境未构建插件)则跳过
}
if (!Array.isArray(urls) || !urls.length) return;
const store = usePluginStore.getState();
await Promise.all(
urls.map(async (url: string) => {
try {
const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source);
if (store.plugins.some((item) => item.id === plugin.id)) return;
store.upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: false, local: true });
} catch (error) {
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
}
}),
);
}
// 本地开发:VITE_DEV_PLUGINS 里的 URL 每次启动都重新拉取(不缓存、不落库),
// 配合 watch 构建即可「改代码→刷新页面」看到最新插件,无需反复安装。
async function loadDevPlugins() {
@@ -108,7 +146,7 @@ async function loadDevPlugins() {
await Promise.all(
urls.map(async (url) => {
try {
const source = await fetchPluginSource(`${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`);
const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id);
activatePlugin(plugin);
@@ -11,6 +11,7 @@ export type InstalledPlugin = {
url: string; // 安装来源,可用于更新
source: string; // 缓存的插件源码,离线可用、版本固定
enabled: boolean;
local?: boolean; // 自动发现于 web/public/plugins 的本地插件(默认关闭,启用时按 url 重新拉取)
installedAt: string;
};