feat(plugin-manager): add upgrade notification for installed plugins and improve version handling

This commit is contained in:
HouYunFei
2026-07-16 10:36:41 +08:00
parent 3f623ea70c
commit 2d2c7e3d5e
4 changed files with 90 additions and 28 deletions
+26 -15
View File
@@ -3,7 +3,7 @@
// CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。 // CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。
// 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。 // 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。
import { build } from "esbuild"; import { build } from "esbuild";
import { mkdir, rm, writeFile } from "node:fs/promises"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -11,15 +11,24 @@ const root = dirname(fileURLToPath(import.meta.url));
const outDir = join(root, "dist"); const outDir = join(root, "dist");
const nodeModules = join(root, "node_modules"); const nodeModules = join(root, "node_modules");
// 官方插件元数据(单一真源:清单从这里生成)。新增官方插件在此登记即可。 // 官方插件登记表:新增官方插件在此登记即可。
// 版本不在这里写死 —— 从各插件的 package.json 读取(单一真源),
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
const OFFICIAL = [ const OFFICIAL = [
{ id: "markdown", dir: "markdown", name: "Markdown 节点", version: "1.0.0", description: "在画布中编辑与渲染 Markdown", icon: "📝" }, { id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
{ id: "svg", dir: "svg", name: "SVG 节点", version: "1.0.0", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" }, { id: "svg", dir: "svg", name: "SVG 节点", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
{ id: "html", dir: "html", name: "HTML 节点", version: "1.0.0", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" }, { id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
{ id: "panorama", dir: "panorama", name: "3D 全景节点", version: "1.0.0", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" }, { id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", version: "1.0.0", description: "可换色、可编辑、可衍生文本节点的便利贴", icon: "📌" }, { id: "sticky-note", dir: "sticky-note", name: "便利贴节点", description: "可换色、可编辑、可衍生文本节点的便利贴", icon: "📌" },
]; ];
// 读取插件 package.json 的 version 作为清单版本的唯一来源
async function readPluginVersion(dir) {
const pkg = JSON.parse(await readFile(join(root, "..", dir, "package.json"), "utf8"));
if (!pkg.version) throw new Error(`插件 ${dir} 的 package.json 缺少 version`);
return pkg.version;
}
await rm(outDir, { recursive: true, force: true }); await rm(outDir, { recursive: true, force: true });
await mkdir(outDir, { recursive: true }); await mkdir(outDir, { recursive: true });
@@ -45,14 +54,16 @@ for (const plugin of OFFICIAL) {
const manifest = { const manifest = {
// 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL // 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL
version: 1, version: 1,
plugins: OFFICIAL.map((plugin) => ({ plugins: await Promise.all(
id: plugin.id, OFFICIAL.map(async (plugin) => ({
name: plugin.name, id: plugin.id,
version: plugin.version, name: plugin.name,
description: plugin.description, version: await readPluginVersion(plugin.dir),
icon: plugin.icon, description: plugin.description,
entry: `${plugin.id}.js`, icon: plugin.icon,
})), entry: `${plugin.id}.js`,
})),
),
}; };
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n"); await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`); console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`);
@@ -4,7 +4,7 @@ import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react
import { canvasThemes } from "@/lib/canvas-theme"; import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader"; import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
import { fetchOfficialPlugins, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry"; import { fetchOfficialPlugins, hasUpgrade, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { useThemeStore } from "@/stores/use-theme-store"; import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store"; import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
@@ -81,12 +81,20 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
}; };
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载 // 已安装插件的操作区:启用开关 +(非本地)更新/卸载
const installedControls = (record: InstalledPlugin) => ( // upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
const installedControls = (record: InstalledPlugin, upgradable = false) => (
<> <>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} /> <Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
{!record.local && ( {!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)), "已更新")} /> <Button
type={upgradable ? "primary" : "text"}
size="small"
icon={<RefreshCw className="size-4" />}
loading={busyId === record.id}
title={upgradable ? "有新版本,点击升级" : "从来源更新"}
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")}
/>
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}> <Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" /> <Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm> </Popconfirm>
@@ -95,6 +103,15 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
</> </>
); );
// 图标外挂一个绿点(右上角),用于「有可升级版本」的提示。
// boxShadow 画一圈与卡片同色的描边环,让绿点从图标上「浮起」。
const withUpgradeDot = (icon: ReactNode) => (
<span className="relative inline-flex">
{icon}
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title="有新版本可升级" />
</span>
);
const versionTag = (version: string) => ( const versionTag = (version: string) => (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}> <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{version} v{version}
@@ -150,14 +167,18 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto"> <div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => { {official.map((entry) => {
const record = recordById.get(entry.id); const record = recordById.get(entry.id);
// 已安装且远程版本更高 → 显示绿点并高亮升级按钮
const upgradable = Boolean(record && hasUpgrade(record.version, entry.version));
const icon = entry.icon || <Puzzle className="size-4" />;
return row( return row(
entry.id, entry.id,
entry.icon || <Puzzle className="size-4" />, upgradable ? withUpgradeDot(icon) : icon,
entry.name, entry.name,
entry.version, // 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
upgradable && record ? `${record.version}${entry.version}` : entry.version,
entry.description, entry.description,
record ? ( record ? (
installedControls(record) installedControls(record, upgradable)
) : ( ) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}> <Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
+20 -7
View File
@@ -55,9 +55,11 @@ function withCacheBust(url: string) {
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`; return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
} }
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用 // 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) { // bustCache=true 时下载绕过 HTTP/CDN 缓存(升级场景必需,避免拿到旧产物),
const source = await fetchPluginSource(url); // 但落库的 url 始终保持干净(不带 ?t=),便于后续再次更新。
export async function installPluginFromUrl(url: string, opts?: { official?: boolean; bustCache?: boolean }) {
const source = await fetchPluginSource(opts?.bustCache ? withCacheBust(url) : url);
const plugin = await evaluatePluginSource(source); const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id); // 覆盖旧版本 deactivatePlugin(plugin.id); // 覆盖旧版本
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true, official: opts?.official }); usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true, official: opts?.official });
@@ -66,7 +68,8 @@ export async function installPluginFromUrl(url: string, opts?: { official?: bool
} }
export async function updatePlugin(record: InstalledPlugin) { export async function updatePlugin(record: InstalledPlugin) {
return installPluginFromUrl(record.url, { official: record.official }); // 升级必须拿到最新产物,强制绕过缓存
return installPluginFromUrl(record.url, { official: record.official, bustCache: true });
} }
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) { export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
@@ -111,7 +114,8 @@ export async function ensurePluginsLoaded() {
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭, // 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。 // 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。 // 已在列表中的:刷新元数据(version/name/description/source)到最新产物,
// 但保留用户的 enabled 开关 —— 否则改了插件版本后,持久化 store 里的旧 version 永不更新。
async function loadLocalPlugins() { async function loadLocalPlugins() {
let urls: unknown; let urls: unknown;
try { try {
@@ -128,8 +132,17 @@ async function loadLocalPlugins() {
try { try {
const source = await fetchPluginSource(withCacheBust(url)); const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source); const plugin = await evaluatePluginSource(source);
if (store.plugins.some((item) => item.id === plugin.id)) return; const existing = store.plugins.find((item) => item.id === plugin.id);
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 }); store.upsert({
id: plugin.id,
name: plugin.name || plugin.id,
version: plugin.version || "0.0.0",
description: plugin.description,
url,
source,
enabled: existing?.enabled ?? false, // 保留用户开关,新发现默认关闭
local: true,
});
} catch (error) { } catch (error) {
console.error(`[plugin] 本地插件发现失败: ${url}`, error); console.error(`[plugin] 本地插件发现失败: ${url}`, error);
} }
+17
View File
@@ -30,3 +30,20 @@ export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(), url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
})); }));
} }
// 语义化版本比较:返回 >0 表示 a 更高,<0 表示 b 更高,0 表示相等。
// 只按 major.minor.patch 数值比较,忽略非数字段(预发布标签等)。
function compareSemver(a: string, b: string): number {
const parse = (v: string) => v.split(".").map((part) => parseInt(part, 10) || 0);
const [pa, pb] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
// 远程版本是否比本地已安装版本更高(即有可升级的更新)
export function hasUpgrade(installedVersion: string, remoteVersion: string): boolean {
return compareSemver(remoteVersion, installedVersion) > 0;
}