mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat(plugin-manager): add upgrade notification for installed plugins and improve version handling
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。
|
||||
// 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。
|
||||
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 { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -11,15 +11,24 @@ const root = dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = join(root, "dist");
|
||||
const nodeModules = join(root, "node_modules");
|
||||
|
||||
// 官方插件元数据(单一真源:清单从这里生成)。新增官方插件在此登记即可。
|
||||
// 官方插件登记表:新增官方插件在此登记即可。
|
||||
// 版本不在这里写死 —— 从各插件的 package.json 读取(单一真源),
|
||||
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
|
||||
const OFFICIAL = [
|
||||
{ id: "markdown", dir: "markdown", name: "Markdown 节点", version: "1.0.0", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
|
||||
{ id: "svg", dir: "svg", name: "SVG 节点", version: "1.0.0", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||
{ id: "html", dir: "html", name: "HTML 节点", version: "1.0.0", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
|
||||
{ id: "panorama", dir: "panorama", name: "3D 全景节点", version: "1.0.0", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
|
||||
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", version: "1.0.0", description: "可换色、可编辑、可衍生文本节点的便利贴", icon: "📌" },
|
||||
{ id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
|
||||
{ id: "svg", dir: "svg", name: "SVG 节点", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||
{ id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
|
||||
{ id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", 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 mkdir(outDir, { recursive: true });
|
||||
|
||||
@@ -45,14 +54,16 @@ for (const plugin of OFFICIAL) {
|
||||
const manifest = {
|
||||
// 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL
|
||||
version: 1,
|
||||
plugins: OFFICIAL.map((plugin) => ({
|
||||
plugins: await Promise.all(
|
||||
OFFICIAL.map(async (plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: plugin.version,
|
||||
version: await readPluginVersion(plugin.dir),
|
||||
description: plugin.description,
|
||||
icon: plugin.icon,
|
||||
entry: `${plugin.id}.js`,
|
||||
})),
|
||||
),
|
||||
};
|
||||
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
|
||||
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 { 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 { 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 ? "已启用" : "已禁用")} />
|
||||
{!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)}>
|
||||
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
|
||||
</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) => (
|
||||
<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}
|
||||
@@ -150,14 +167,18 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
|
||||
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
|
||||
{official.map((entry) => {
|
||||
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(
|
||||
entry.id,
|
||||
entry.icon || <Puzzle className="size-4" />,
|
||||
upgradable ? withUpgradeDot(icon) : icon,
|
||||
entry.name,
|
||||
entry.version,
|
||||
// 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
|
||||
upgradable && record ? `${record.version} → ${entry.version}` : entry.version,
|
||||
entry.description,
|
||||
record ? (
|
||||
installedControls(record)
|
||||
installedControls(record, upgradable)
|
||||
) : (
|
||||
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
|
||||
安装
|
||||
|
||||
@@ -55,9 +55,11 @@ function withCacheBust(url: string) {
|
||||
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
|
||||
}
|
||||
|
||||
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
|
||||
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) {
|
||||
const source = await fetchPluginSource(url);
|
||||
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用。
|
||||
// bustCache=true 时下载绕过 HTTP/CDN 缓存(升级场景必需,避免拿到旧产物),
|
||||
// 但落库的 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);
|
||||
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 });
|
||||
@@ -66,7 +68,8 @@ export async function installPluginFromUrl(url: string, opts?: { official?: bool
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -111,7 +114,8 @@ export async function ensurePluginsLoaded() {
|
||||
|
||||
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
|
||||
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
|
||||
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
|
||||
// 已在列表中的:刷新元数据(version/name/description/source)到最新产物,
|
||||
// 但保留用户的 enabled 开关 —— 否则改了插件版本后,持久化 store 里的旧 version 永不更新。
|
||||
async function loadLocalPlugins() {
|
||||
let urls: unknown;
|
||||
try {
|
||||
@@ -128,8 +132,17 @@ async function loadLocalPlugins() {
|
||||
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 });
|
||||
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: existing?.enabled ?? false, // 保留用户开关,新发现默认关闭
|
||||
local: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
}
|
||||
|
||||
// 语义化版本比较:返回 >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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user