From 044e220d146af7947f6f487e67bf9fd911876835 Mon Sep 17 00:00:00 2001 From: jay <337278848@qq.com> Date: Sat, 12 Sep 2026 21:16:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(plugins):=20=E6=8F=92=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20-=20=E4=BF=AE=E6=AD=A3=E5=AE=98=E6=96=B9=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E6=8F=92=E4=BB=B6=E7=9A=84=E5=88=86=E7=B1=BB=E4=B8=8E?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AI 审美分析和剪辑工作台在管理页显示为"已停用",原因是三处插件 分类清单不一致: - 后端 officialApplicationPolicies 缺少 editor-shell - 管理页 officialApplicationIds 同时缺少 ai-art-critique 和 editor-shell - 用户插件页另有一份重复清单 后端把这两个插件当作系统协议处理(Kind=protocol),platformAvailable 回退为 false,于是返回"管理员已停用该插件",管理页据此渲染成"已停用"。 修复: - 后端补上 editor-shell 应用策略(权威修复,决定平台可用性) - 前端抽取唯一清单 OFFICIAL_APPLICATION_PLUGIN_IDS,管理页与用户插件页 共用,删除两份重复实现 - 补前后端回归测试,锁定这 8 个官方应用插件 验证:tsc --noEmit 通过;前端 17 项测试通过;后端 TestEditorShell* 与 既有插件测试通过;前后端镜像构建通过。 --- backend/internal/app/plugin_management.go | 5 ++ .../internal/app/plugin_management_test.go | 41 +++++++++++++++ .../plugins/builtin/editor/editor-shell.tsx | 4 +- web/src/lib/plugins/official-applications.ts | 32 ++++++++++++ web/src/pages/admin/plugins/plugins-page.tsx | 7 +-- web/src/pages/plugins/index.tsx | 13 ++--- web/test/official-application-plugins.test.ts | 52 +++++++++++++++++++ 7 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 web/src/lib/plugins/official-applications.ts create mode 100644 web/test/official-application-plugins.test.ts diff --git a/backend/internal/app/plugin_management.go b/backend/internal/app/plugin_management.go index a63959b7e..c64f541eb 100644 --- a/backend/internal/app/plugin_management.go +++ b/backend/internal/app/plugin_management.go @@ -14,6 +14,7 @@ const ( PluginPortraitClearance = "portrait-clearance" PluginAIArtCritique = "ai-art-critique" PluginMediaConversion = "media-conversion" + PluginEditorShell = "editor-shell" PluginOriginOfficial = "official" PluginOriginSystem = "system" @@ -85,6 +86,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{ diff --git a/backend/internal/app/plugin_management_test.go b/backend/internal/app/plugin_management_test.go index 703924d69..4a2672bf5 100644 --- a/backend/internal/app/plugin_management_test.go +++ b/backend/internal/app/plugin_management_test.go @@ -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) + } +} diff --git a/web/src/lib/plugins/builtin/editor/editor-shell.tsx b/web/src/lib/plugins/builtin/editor/editor-shell.tsx index 4ee28ba5f..12b3bdc91 100644 --- a/web/src/lib/plugins/builtin/editor/editor-shell.tsx +++ b/web/src/lib/plugins/builtin/editor/editor-shell.tsx @@ -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 编辑八个工作台插槽。", diff --git a/web/src/lib/plugins/official-applications.ts b/web/src/lib/plugins/official-applications.ts new file mode 100644 index 000000000..c41aa6469 --- /dev/null +++ b/web/src/lib/plugins/official-applications.ts @@ -0,0 +1,32 @@ +import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts"; +import { MEDIA_CONVERSION_PLUGIN_ID } from "@/lib/plugins/builtin/media-conversion"; +import { EAGLE_PLUGIN_ID } from "@/lib/plugins/builtin/eagle"; +import { PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer"; +import { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; +import { PORTRAIT_CLEARANCE_PLUGIN_ID } from "@/lib/portrait-clearance/contracts"; + +import { EDITOR_SHELL_PLUGIN_ID } from "./builtin/editor/editor-shell"; + +/** + * 官方“应用型”插件清单:这些插件在管理页按用户自主启停处理,而不是系统协议。 + * + * 之前管理页、用户插件页和后端各自维护一份清单,管理页漏掉 AI 审美分析和剪辑 + * 工作台,导致二者被误判为系统协议并在管理页显示“已停用”。这里收敛为唯一来源。 + */ +export const OFFICIAL_APPLICATION_PLUGIN_IDS = [ + RUNNINGHUB_PLUGIN_ID, + COMFYUI_PLUGIN_ID, + EAGLE_PLUGIN_ID, + PROMPT_OPTIMIZER_PLUGIN_ID, + PORTRAIT_CLEARANCE_PLUGIN_ID, + ART_CRITIQUE_PLUGIN_ID, + MEDIA_CONVERSION_PLUGIN_ID, + EDITOR_SHELL_PLUGIN_ID, +] as const; + +const officialApplicationIdSet = new Set(OFFICIAL_APPLICATION_PLUGIN_IDS); + +/** 判断插件是否为官方应用型插件(用户可在插件页自主启停)。 */ +export function isOfficialApplicationPluginId(pluginId: string) { + return officialApplicationIdSet.has(pluginId); +} diff --git a/web/src/pages/admin/plugins/plugins-page.tsx b/web/src/pages/admin/plugins/plugins-page.tsx index 39b9bbe58..69361e811 100644 --- a/web/src/pages/admin/plugins/plugins-page.tsx +++ b/web/src/pages/admin/plugins/plugins-page.tsx @@ -8,9 +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 { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; -import { MEDIA_CONVERSION_PLUGIN_ID } from "@/lib/plugins/builtin/media-conversion"; +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"; @@ -27,8 +26,6 @@ type AdminPluginItem = { error?: string; }; -const officialApplicationIds = new Set([RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance", MEDIA_CONVERSION_PLUGIN_ID]); - export default function AdminPluginsPage() { const { message, modal } = App.useApp(); const [plugins, setPlugins] = useState([]); @@ -363,7 +360,7 @@ function PluginBrandIcon({ pluginId }: { pluginId: string }) { function mergePlugins(remote: BackendPlugin[]): AdminPluginItem[] { const byId = new Map(); 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", diff --git a/web/src/pages/plugins/index.tsx b/web/src/pages/plugins/index.tsx index b3c06b3e5..06cb2a53d 100644 --- a/web/src/pages/plugins/index.tsx +++ b/web/src/pages/plugins/index.tsx @@ -10,8 +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 { COMFYUI_PLUGIN_ID, RUNNINGHUB_PLUGIN_ID } from "@/lib/plugins/builtin/workflows"; -import { MEDIA_CONVERSION_PLUGIN_ID } from "@/lib/plugins/builtin/media-conversion"; -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"; @@ -142,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); @@ -186,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 = { 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) { @@ -627,14 +626,10 @@ function toRegisteredPlugin(plugin: BackendPlugin): RegisteredPlugin { return { manifest: plugin.manifest, source: plugin.source }; } -function isOfficialApplicationPlugin(pluginId: string) { - return [RUNNINGHUB_PLUGIN_ID, COMFYUI_PLUGIN_ID, EAGLE_PLUGIN_ID, PROMPT_OPTIMIZER_PLUGIN_ID, "portrait-clearance", ART_CRITIQUE_PLUGIN_ID, MEDIA_CONVERSION_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 "系统插件"; } diff --git a/web/test/official-application-plugins.test.ts b/web/test/official-application-plugins.test.ts new file mode 100644 index 000000000..97933bc2a --- /dev/null +++ b/web/test/official-application-plugins.test.ts @@ -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("portrait-clearance")).toBe(true); + expect(isOfficialApplicationPluginId("media-conversion")).toBe(true); + expect(isOfficialApplicationPluginId("prompt-optimizer")).toBe(true); + expect(isOfficialApplicationPluginId("eagle-asset-connector")).toBe(true); + expect(isOfficialApplicationPluginId("runninghub-workflow-provider")).toBe(true); + expect(isOfficialApplicationPluginId("comfyui-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); + }); + + 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(8); + }); + + 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) {"); + }); +});