fix(plugins): PR #493 - 合并分类修复并保留本地插件删减

This commit is contained in:
ddCat
2026-09-14 19:47:03 +08:00
7 changed files with 134 additions and 13 deletions

View File

@@ -14,6 +14,7 @@ const (
PluginPortraitClearance = "portrait-clearance"
PluginAIArtCritique = "ai-art-critique"
PluginMediaConversion = "media-conversion"
PluginEditorShell = "editor-shell"
PluginOriginOfficial = "official"
PluginOriginSystem = "system"
@@ -81,6 +82,10 @@ var officialApplicationPolicies = map[string]PluginManagementView{
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone,
},
PluginEditorShell: {
Origin: PluginOriginOfficial, Kind: PluginKindApplication,
ActivationScope: PluginScopeUser, ConfigurationScope: PluginConfigurationNone,
},
}
var systemPaymentPolicies = map[string]PluginManagementView{

View File

@@ -76,3 +76,44 @@ func TestArtCritiqueIsUserToggleableApplication(t *testing.T) {
t.Fatalf("AI art critique policy = %#v", policy)
}
}
// Regression: editor-shell was missing from officialApplicationPolicies, so the
// backend classified it as a system protocol and reported it to the admin page
// as "管理员已停用该插件" even though nothing disabled it.
func TestEditorShellIsUserToggleableApplication(t *testing.T) {
policy := pluginManagement(PluginEditorShell, "bundled")
if policy.Origin != PluginOriginOfficial || policy.Kind != PluginKindApplication || policy.ActivationScope != PluginScopeUser || policy.ConfigurationScope != PluginConfigurationNone {
t.Fatalf("editor shell policy = %#v", policy)
}
}
func TestEditorShellReportsPlatformAvailableWithoutPlatformState(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.PluginPlatformState{}, &model.UserPluginState{}, &model.AdminAuditEvent{}); err != nil {
t.Fatal(err)
}
center, err := newPluginRuntime(t.TempDir())
if err != nil {
t.Fatal(err)
}
svc := &Service{repo: repository.New(db), pluginRuntime: center}
user := &model.User{ID: "user-1", Role: model.UserRoleUser}
states, err := svc.PluginStatesForUser(user)
if err != nil {
t.Fatal(err)
}
state, ok := states[PluginEditorShell]
if !ok {
t.Fatalf("editor shell missing from plugin states: %#v", states)
}
if !state.PlatformAvailable || !state.CanToggle {
t.Fatalf("editor shell should be a user-toggleable application, got %#v", state)
}
if state.BlockedReason == "管理员已停用该插件" {
t.Fatalf("editor shell reported as admin-disabled: %#v", state)
}
}

View File

@@ -10,9 +10,11 @@ import { EditorTranscription } from "./editor-transcription";
import { EditorExport } from "./editor-export";
import { EditorAiAssistant } from "./editor-ai-assistant";
export const EDITOR_SHELL_PLUGIN_ID = "editor-shell";
const manifest: PluginManifestV2 = {
apiVersion: "yingce.plugin/v2",
id: "editor-shell",
id: EDITOR_SHELL_PLUGIN_ID,
name: "剪辑工作台",
version: "0.1.0",
description: "注册时间线、预览、检查器、素材、字幕、转写、导出和 AI 编辑八个工作台插槽。",

View File

@@ -0,0 +1,27 @@
import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { EDITOR_SHELL_PLUGIN_ID } from "./builtin/editor/editor-shell";
/**
* 官方“应用型”插件清单:这些插件在管理页按用户自主启停处理,而不是系统协议。
*
* 之前管理页、用户插件页和后端各自维护一份清单,管理页漏掉 AI 审美分析和剪辑
* 工作台,导致二者被误判为系统协议并在管理页显示“已停用”。这里收敛为唯一来源。
*/
export const OFFICIAL_APPLICATION_PLUGIN_IDS = [
RUNNINGHUB_PLUGIN_ID,
EAGLE_PLUGIN_ID,
PROMPT_OPTIMIZER_PLUGIN_ID,
ART_CRITIQUE_PLUGIN_ID,
EDITOR_SHELL_PLUGIN_ID,
] as const;
const officialApplicationIdSet = new Set<string>(OFFICIAL_APPLICATION_PLUGIN_IDS);
/** 判断插件是否为官方应用型插件(用户可在插件页自主启停)。 */
export function isOfficialApplicationPluginId(pluginId: string) {
return officialApplicationIdSet.has(pluginId);
}

View File

@@ -8,8 +8,8 @@ import { useEffect, useMemo, useState } from "react";
import { PaginationBar } from "@/components/layout/workspace-page";
import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { isOfficialApplicationPluginId } from "@/lib/plugins/official-applications";
import { listRegisteredPlugins } from "@/lib/plugins/plugin-registry";
import type { PluginManifest, PluginManifestV2 } from "@/lib/plugins/plugin-types";
import { fetchAdminPlugins, setPluginPlatformAvailability, uninstallPlugin, uploadPlugin, type AdminPluginState, type BackendPlugin, type PluginManagement } from "@/services/api/plugins";
@@ -26,8 +26,6 @@ type AdminPluginItem = {
error?: string;
};
const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID]);
export default function AdminPluginsPage() {
const { message, modal } = App.useApp();
const [plugins, setPlugins] = useState<BackendPlugin[]>([]);
@@ -362,7 +360,7 @@ function PluginBrandIcon({ pluginId }: { pluginId: string }) {
function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] {
const byId = new Map<string, AdminPluginItem>();
for (const plugin of listRegisteredPlugins()) {
const application = officialApplicationIds.has(plugin.manifest.id);
const application = isOfficialApplicationPluginId(plugin.manifest.id);
byId.set(plugin.manifest.id, {
manifest: plugin.manifest,
source: plugin.source || "bundled",

View File

@@ -10,7 +10,7 @@ import "@/lib/plugins/builtin";
import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle";
import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
import { RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows";
import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts";
import { isOfficialApplicationPluginId } from "@/lib/plugins/official-applications";
import type { PluginManifest, PluginManifestV2, RegisteredPlugin } from "@/lib/plugins/plugin-types";
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
import { fetchPlugins, setUserPluginEnabled, type BackendPlugin, type PluginState } from "@/services/api/plugins";
@@ -141,7 +141,7 @@ export default function PluginsPage() {
const normalizedSearch = search.trim().toLocaleLowerCase();
return registeredPlugins.filter((plugin) => {
const state = pluginStates[plugin.manifest.id];
const isApplicationPlugin = backendPluginById.get(plugin.manifest.id)?.management.kind === "application" || isOfficialApplicationPlugin(plugin.manifest.id);
const isApplicationPlugin = backendPluginById.get(plugin.manifest.id)?.management.kind === "application" || isOfficialApplicationPluginId(plugin.manifest.id);
if (user?.role !== "admin" && !features.systemPluginsVisibleToUsers && !isApplicationPlugin) return false;
const installation = installations.find((item) => item.manifest.id === plugin.manifest.id);
const enabled = state?.effectiveEnabled ?? Boolean(installation?.enabled);
@@ -185,7 +185,7 @@ export default function PluginsPage() {
}, [pluginSections, scrollTarget]);
const categoryCounts = useMemo(() => {
const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPlugin(plugin.manifest.id));
const visiblePlugins = registeredPlugins.filter((plugin) => user?.role === "admin" || features.systemPluginsVisibleToUsers || isOfficialApplicationPluginId(plugin.manifest.id));
const counts: Record<string, number> = { all: visiblePlugins.length, text: 0, image: 0, video: 0, audio: 0, payment: 0, other: 0 };
for (const plugin of visiblePlugins) {
for (const section of protocolSectionMeta) {
@@ -626,14 +626,10 @@ function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin {
return { manifest: plugin.manifest, source: plugin.source };
}
function isOfficialApplicationPlugin(pluginId: string) {
return [RUNNINGHUB_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, ART_CRITIQUE_PLUGIN_ID].includes(pluginId);
}
function pluginSourceLabel(plugin: RegisteredPlugin, state?: PluginState) {
if (plugin.source === "uploaded") return "自定义插件";
if (plugin.source === "system") return "系统插件";
if (state?.canToggle || isOfficialApplicationPlugin(plugin.manifest.id)) return "官方插件";
if (state?.canToggle || isOfficialApplicationPluginId(plugin.manifest.id)) return "官方插件";
return "系统插件";
}

View File

@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
OFFICIAL_APPLICATION_PLUGIN_IDS,
isOfficialApplicationPluginId,
} from "../src/lib/plugins/official-applications";
describe("official application plugin classification", () => {
test("covers every built-in application plugin, including editor and art critique", () => {
// Regression: the admin plugin page kept its own list and missed these
// two, so both rendered as disabled system protocols.
expect(isOfficialApplicationPluginId("editor-shell")).toBe(true);
expect(isOfficialApplicationPluginId("ai-art-critique")).toBe(true);
expect(isOfficialApplicationPluginId("prompt-optimizer")).toBe(true);
expect(isOfficialApplicationPluginId("eagle-asset-connector")).toBe(true);
expect(isOfficialApplicationPluginId("runninghub-workflow-provider")).toBe(true);
});
test("does not treat uploaded or unknown plugins as official applications", () => {
expect(isOfficialApplicationPluginId("some-uploaded-plugin")).toBe(false);
expect(isOfficialApplicationPluginId("")).toBe(false);
expect(isOfficialApplicationPluginId("portrait-clearance")).toBe(false);
expect(isOfficialApplicationPluginId("media-conversion")).toBe(false);
expect(isOfficialApplicationPluginId("comfyui-workflow-provider")).toBe(false);
});
test("exposes a single de-duplicated id list", () => {
const ids = [...OFFICIAL_APPLICATION_PLUGIN_IDS];
expect(new Set(ids).size).toBe(ids.length);
expect(ids.length).toBe(5);
});
test("both plugin pages consume the shared list instead of a local copy", () => {
const adminPage = readFileSync(
resolve(import.meta.dir, "../src/pages/admin/plugins/plugins-page.tsx"),
"utf8",
);
const userPage = readFileSync(
resolve(import.meta.dir, "../src/pages/plugins/index.tsx"),
"utf8",
);
expect(adminPage).toContain("isOfficialApplicationPluginId");
expect(userPage).toContain("isOfficialApplicationPluginId");
// No page may reintroduce a hand-maintained id array.
expect(adminPage).not.toContain("const officialApplicationIds = new Set([");
expect(userPage).not.toContain("function isOfficialApplicationPlugin(pluginId: string) {");
});
});