diff --git a/plugins/canvas/README.md b/plugins/canvas/README.md index fe2d39a..5cdeab8 100644 --- a/plugins/canvas/README.md +++ b/plugins/canvas/README.md @@ -29,13 +29,17 @@ npm run dev # watch,改动自动构建并同步 ## 本地开发 -`npm run dev` 起 watch,在 `web/.env.local` 声明开发插件(逗号分隔多个): +`npm run dev` 起 watch,产物会同步到 `web/public/plugins/.js`。此后有两种方式在画布里用到它: + +**方式一(推荐):自动发现。** 画布启动时会扫描 `web/public/plugins/` 下的插件,自动加入「节点插件」管理器列表,**默认关闭**;打开开关即启用。无需手动填 URL,启用时会按文件重新拉取,配合 watch 改完刷新即最新。 + +**方式二:`VITE_DEV_PLUGINS`。** 在 `web/.env.local` 声明(逗号分隔多个),这些插件每次刷新页面都**重新拉取并直接激活**(不缓存、不落库、无开关): ```env VITE_DEV_PLUGINS=/plugins/markdown.js,/plugins/svg.js ``` -再起画布 `web`(`npm run dev`)。`VITE_DEV_PLUGINS` 里的插件每次刷新页面都会**重新拉取**(不缓存、不落库),流程即:改 `src/index.jsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。 +再起画布 `web`(`npm run dev`)。流程即:改 `src/index.jsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。 ## 插件文件构成 diff --git a/web/src/components/canvas/canvas-plugin-manager-modal.tsx b/web/src/components/canvas/canvas-plugin-manager-modal.tsx index c68906a..6aea15a 100644 --- a/web/src/components/canvas/canvas-plugin-manager-modal.tsx +++ b/web/src/components/canvas/canvas-plugin-manager-modal.tsx @@ -80,6 +80,11 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC v{record.version} + {record.local && ( + + 本地 + + )}
{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 ? "已启用" : "已禁用")} /> -
)) )} diff --git a/web/src/lib/canvas/plugin-loader.ts b/web/src/lib/canvas/plugin-loader.ts index 71ea019..45fade9 100644 --- a/web/src/lib/canvas/plugin-loader.ts +++ b/web/src/lib/canvas/plugin-loader.ts @@ -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); diff --git a/web/src/stores/canvas/use-plugin-store.ts b/web/src/stores/canvas/use-plugin-store.ts index 25c940a..9d37e90 100644 --- a/web/src/stores/canvas/use-plugin-store.ts +++ b/web/src/stores/canvas/use-plugin-store.ts @@ -11,6 +11,7 @@ export type InstalledPlugin = { url: string; // 安装来源,可用于更新 source: string; // 缓存的插件源码,离线可用、版本固定 enabled: boolean; + local?: boolean; // 自动发现于 web/public/plugins 的本地插件(默认关闭,启用时按 url 重新拉取) installedAt: string; }; diff --git a/web/vite.config.ts b/web/vite.config.ts index a38d304..1d00088 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,8 +1,8 @@ -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; import { parseChangelog } from "./src/lib/release"; @@ -10,9 +10,37 @@ const webDir = dirname(fileURLToPath(import.meta.url)); const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev"; const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8"); +// 暴露 /plugins/index.json:列出 public/plugins 下的本地插件文件, +// 供前端自动发现并加入插件列表(默认关闭)。dev 下实时读目录,构建时产出静态清单。 +function localPluginsManifest(): Plugin { + const pluginsDir = resolve(webDir, "public/plugins"); + const listLocalPlugins = () => { + try { + return readdirSync(pluginsDir) + .filter((file) => file.endsWith(".js")) + .sort() + .map((file) => `/plugins/${file}`); + } catch { + return []; + } + }; + return { + name: "local-plugins-manifest", + configureServer(server) { + server.middlewares.use("/plugins/index.json", (_req, res) => { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(listLocalPlugins())); + }); + }, + generateBundle() { + this.emitFile({ type: "asset", fileName: "plugins/index.json", source: JSON.stringify(listLocalPlugins()) }); + }, + }; +} + export default defineConfig({ base: process.env.VITE_BASE || "/", - plugins: [react()], + plugins: [react(), localPluginsManifest()], resolve: { alias: { "@": resolve(webDir, "src"),