diff --git a/docs/module-federation-guide.md b/docs/module-federation-guide.md index dd364809..1cb50b9c 100644 --- a/docs/module-federation-guide.md +++ b/docs/module-federation-guide.md @@ -141,6 +141,8 @@ const props = defineProps({ type: Object, default: () => {}, }, + pluginId: { type: String, default: '' }, + sourcePluginId: { type: String, default: '' }, nativeSubscribe: { type: Function, default: null, @@ -189,6 +191,8 @@ const props = defineProps({ type: Object, default: () => {}, }, + pluginId: { type: String, default: '' }, + sourcePluginId: { type: String, default: '' }, nativeSubscribe: { type: Function, default: null, @@ -248,6 +252,9 @@ const props = defineProps({ type: Boolean, default: true, }, + api: { type: Object, default: () => ({}) }, + pluginId: { type: String, default: '' }, + sourcePluginId: { type: String, default: '' }, nativeSubscribe: { type: Function, default: null, @@ -290,6 +297,7 @@ const props = defineProps({ | `nativeSubscribe` | 打开主应用原生订阅交互 | | `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 | | `pluginId` | 当前插件 ID | +| `sourcePluginId` | 虚拟分身共享资源的源插件 ID;普通插件为空 | ```vue @@ -317,6 +326,8 @@ const emit = defineEmits(['action']) | 能力 | Page | Config | Dashboard | AppPage | 调用方式 | | ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- | | 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop | +| 当前实例 ID | ✓ | ✓ | ✓ | ✓ | `pluginId` prop | +| 共享源码 ID | ✓ | ✓ | ✓ | ✓ | `sourcePluginId` prop;普通插件为空 | | 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` | | 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` | | 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` | @@ -324,6 +335,11 @@ const emit = defineEmits(['action']) `nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。 +V3 新建插件分身会复用源插件的同一份联邦产物。宿主传入的 `api` 已绑定当前 +`pluginId`:即使旧组件仍调用 `plugin//...`,也会被映射到实例 API。 +新组件应直接用 `pluginId` 拼接路径,并始终优先使用 `api` prop;只读取全局 +`window.MoviePilotAPI` 会绕过实例作用域,不适合多实例插件。 + ### 5.6 玻璃光学表面 主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学: @@ -565,8 +581,8 @@ def get_render_mode() -> Tuple[str, str]: - 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear` ```typescript -// 演示使用api模块调用插件接口 -recentItems.value = await props.api.get(`plugin/MyPlugin/history`) +// 使用宿主传入的当前实例 ID,普通插件和虚拟分身使用同一份组件代码 +recentItems.value = await props.api.get(`plugin/${props.pluginId}/history`) ``` ```python diff --git a/src/api/__tests__/pluginInstance.spec.ts b/src/api/__tests__/pluginInstance.spec.ts new file mode 100644 index 00000000..af3573c2 --- /dev/null +++ b/src/api/__tests__/pluginInstance.spec.ts @@ -0,0 +1,57 @@ +import { createScopedPluginApi } from '@/api/pluginInstance' +import type { PluginApiClient } from '@/api/client' +import { describe, expect, it, vi } from 'vitest' + +/** 创建可调用且带 Axios 风格方法的最小插件客户端。 */ +function createClient() { + const client = Object.assign(vi.fn(), { + get: vi.fn(), + post: vi.fn(), + request: vi.fn(), + }) + return client as unknown as PluginApiClient +} + +describe('createScopedPluginApi', () => { + it('rewrites only the source plugin dynamic API namespace', () => { + const client = createClient() + const scoped = createScopedPluginApi(client, 'DemoPluginwork', 'DemoPlugin') + + void scoped.get('plugin/DemoPlugin/items') + void scoped.post('/api/v1/plugin/demoplugin/action?sync=1', { value: 1 }) + void scoped.get('system/config') + + expect(client.get).toHaveBeenNthCalledWith(1, 'plugin/DemoPluginwork/items') + expect(client.post).toHaveBeenCalledWith( + '/api/v1/plugin/DemoPluginwork/action?sync=1', + { value: 1 }, + ) + expect(client.get).toHaveBeenNthCalledWith(2, 'system/config') + }) + + it('supports callable and request-config forms without mutating input', () => { + const client = createClient() + const scoped = createScopedPluginApi(client, 'DemoPluginhome', 'DemoPlugin') + const config = { url: '/plugin/DemoPlugin/status', method: 'GET' } + + void scoped(config) + void scoped.request(config) + + expect(client).toHaveBeenCalledWith({ + url: '/plugin/DemoPluginhome/status', + method: 'GET', + }) + expect(client.request).toHaveBeenCalledWith({ + url: '/plugin/DemoPluginhome/status', + method: 'GET', + }) + expect(config.url).toBe('/plugin/DemoPlugin/status') + }) + + it('returns the original client for ordinary plugins', () => { + const client = createClient() + + expect(createScopedPluginApi(client, 'DemoPlugin')).toBe(client) + expect(createScopedPluginApi(client, 'DemoPlugin', 'DemoPlugin')).toBe(client) + }) +}) diff --git a/src/api/index.ts b/src/api/index.ts index 2b6a352e..beb0ca93 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -16,6 +16,7 @@ import { type DataApiClient, type PluginApiClient, } from './client' +import { createScopedPluginApi } from './pluginInstance' /** 带连接探测和反馈策略的 MoviePilot 请求配置。 */ export interface ConnectionAwareRequestConfig extends AxiosRequestConfig { @@ -68,6 +69,7 @@ const { api, pluginApi } = createApiClients({ }, resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]), }) +const pluginInstanceApis = new Map() declare global { interface Window { @@ -89,10 +91,29 @@ function initializeClient(instance: AxiosInstance | DataApiClient) { }) } +/** 返回复用同一拦截器链、但把源插件 API 映射到实例命名空间的客户端。 */ +function createPluginInstanceApi(instanceId: string, sourcePluginId?: string): PluginApiClient { + if (!sourcePluginId || instanceId === sourcePluginId) return pluginApi + const cacheKey = `${instanceId}\u0000${sourcePluginId}` + let scopedApi = pluginInstanceApis.get(cacheKey) + if (!scopedApi) { + scopedApi = createScopedPluginApi(pluginApi, instanceId, sourcePluginId) + pluginInstanceApis.set(cacheKey, scopedApi) + } + return scopedApi +} + // 插件远程组件接收 endpoint 的最终 payload,内部页面默认使用严格 envelope 解包客户端。 if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi -export { ApiRequestError, getApiBusinessErrorMessage, isApiBusinessFailure, isApiResponse, pluginApi } +export { + ApiRequestError, + createPluginInstanceApi, + getApiBusinessErrorMessage, + isApiBusinessFailure, + isApiResponse, + pluginApi, +} export type { ApiFeedbackMode, DataApiClient, PluginApiClient } export default api diff --git a/src/api/pluginInstance.ts b/src/api/pluginInstance.ts new file mode 100644 index 00000000..fb561aab --- /dev/null +++ b/src/api/pluginInstance.ts @@ -0,0 +1,83 @@ +import type { AxiosRequestConfig } from 'axios' +import type { PluginApiClient } from './client' + +const URL_METHODS = new Set([ + 'delete', + 'get', + 'head', + 'options', + 'patch', + 'patchForm', + 'post', + 'postForm', + 'put', + 'putForm', +]) + +/** 转义插件 ID,避免其进入正则表达式后改变路径匹配语义。 */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** 把源插件动态 API 路径映射到当前虚拟实例的服务端命名空间。 */ +function rewritePluginUrl(url: string, instanceId: string, sourcePluginId: string): string { + if (!instanceId || !sourcePluginId || instanceId === sourcePluginId) return url + const sourcePattern = new RegExp( + `(^|/)plugin/${escapeRegExp(sourcePluginId)}(?=/|[?#]|$)`, + 'i', + ) + return url.replace(sourcePattern, `$1plugin/${instanceId}`) +} + +/** 复制请求配置并改写其中的 URL,避免修改远程插件持有的原对象。 */ +function rewriteRequestConfig( + config: AxiosRequestConfig, + instanceId: string, + sourcePluginId: string, +): AxiosRequestConfig { + if (typeof config.url !== 'string') return config + return { + ...config, + url: rewritePluginUrl(config.url, instanceId, sourcePluginId), + } +} + +/** + * 创建实例作用域 API。 + * + * 仅改写插件自己的动态 API 前缀,其余系统 API 和 Axios 能力保持原合同, + * 因而无需要求存量联邦插件改造路径拼接方式。 + */ +export function createScopedPluginApi( + client: PluginApiClient, + instanceId: string, + sourcePluginId?: string, +): PluginApiClient { + if (!sourcePluginId || instanceId === sourcePluginId) return client + + return new Proxy(client, { + apply(target, thisArg, argumentList: unknown[]) { + const [request, ...rest] = argumentList + const rewritten = + typeof request === 'string' + ? rewritePluginUrl(request, instanceId, sourcePluginId) + : request && typeof request === 'object' + ? rewriteRequestConfig(request as AxiosRequestConfig, instanceId, sourcePluginId) + : request + return Reflect.apply(target, thisArg, [rewritten, ...rest]) + }, + get(target, property, receiver) { + const value = Reflect.get(target, property, receiver) + if (typeof property !== 'string' || typeof value !== 'function') return value + if (property === 'request') { + return (config: AxiosRequestConfig) => + value.call(target, rewriteRequestConfig(config, instanceId, sourcePluginId)) + } + if (URL_METHODS.has(property)) { + return (url: string, ...args: unknown[]) => + value.call(target, rewritePluginUrl(url, instanceId, sourcePluginId), ...args) + } + return value.bind(target) + }, + }) as PluginApiClient +} diff --git a/src/api/types.ts b/src/api/types.ts index e283cbeb..8bbea856 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -985,6 +985,12 @@ export interface Plugin { rating_count?: number // 当前安装实例评分 user_rating?: number | null + // 共享代码和联邦资源的源插件 ID + source_plugin_id?: string + // 是否为共享源码的虚拟实例 + is_instance?: boolean + // 实例实现模式 + instance_mode?: 'virtual' } export interface PluginRuntimeSummary { @@ -1078,6 +1084,8 @@ export interface DashboardItem { elements: RenderProps[] // 渲染方式 render_mode?: string + // 共享联邦资源的源插件 ID + source_plugin_id?: string } // 种子信息 diff --git a/src/components/cards/PluginCard.vue b/src/components/cards/PluginCard.vue index dd9a91ab..17a09d2a 100644 --- a/src/components/cards/PluginCard.vue +++ b/src/components/cards/PluginCard.vue @@ -480,7 +480,6 @@ async function executePluginClone(cloneForm: { suffix: string name: string description: string - version: string icon: string }) { if (!cloneForm.suffix.trim()) { @@ -497,7 +496,6 @@ async function executePluginClone(cloneForm: { suffix: cloneForm.suffix.trim(), name: cloneForm.name.trim(), description: cloneForm.description.trim(), - version: cloneForm.version.trim(), icon: cloneForm.icon.trim(), }, { feedback: 'silent' }, diff --git a/src/components/cards/__tests__/PluginCard.spec.ts b/src/components/cards/__tests__/PluginCard.spec.ts index 31dc86c5..2ac1bb99 100644 --- a/src/components/cards/__tests__/PluginCard.spec.ts +++ b/src/components/cards/__tests__/PluginCard.spec.ts @@ -261,7 +261,6 @@ describe('PluginCard lifecycle actions', () => { suffix: string name: string description: string - version: string icon: string }) => Promise } @@ -269,7 +268,6 @@ describe('PluginCard lifecycle actions', () => { suffix: ' Test ', name: '演示分身', description: ' 独立配置 ', - version: ' 1.0.1 ', icon: ' https://example.com/icon.png ', }) @@ -277,7 +275,6 @@ describe('PluginCard lifecycle actions', () => { suffix: 'Test', name: '演示分身', description: '独立配置', - version: '1.0.1', icon: 'https://example.com/icon.png', }) expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!') @@ -296,11 +293,10 @@ describe('PluginCard lifecycle actions', () => { suffix: string name: string description: string - version: string icon: string }) => Promise } - await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' }) + await cloneEvents.clone({ suffix: ' ', name: '', description: '', icon: '' }) expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空') expect(mocks.apiPost).not.toHaveBeenCalled() @@ -316,11 +312,10 @@ describe('PluginCard lifecycle actions', () => { suffix: string name: string description: string - version: string icon: string }) => Promise } - const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' } + const form = { suffix: 'Test', name: '测试', description: '', icon: '' } await cloneEvents.clone(form) expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在') expect(mocks.dialogCloses[0]).not.toHaveBeenCalled() diff --git a/src/components/dialog/PluginCloneDialog.vue b/src/components/dialog/PluginCloneDialog.vue index d3917049..c2b82428 100644 --- a/src/components/dialog/PluginCloneDialog.vue +++ b/src/components/dialog/PluginCloneDialog.vue @@ -42,7 +42,6 @@ const cloneForm = ref({ suffix: '', name: '', description: '', - version: '', icon: '', }) @@ -52,7 +51,6 @@ function initializeCloneForm() { suffix: '', name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }), description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }), - version: props.plugin?.plugin_version || '1.0', icon: props.plugin?.plugin_icon || '', } } @@ -121,18 +119,7 @@ onMounted(() => { /> - - - - - + + createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id), +) + // 是否刷新 const isRefreshed = ref(false) @@ -240,7 +245,9 @@ onBeforeMount(async () => { + createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id), +) + // 是否刷新 const isRefreshed = ref(false) // 只有成功取得合法页面响应时才允许展示空页面状态。 @@ -183,7 +188,9 @@ onMounted(() => { { }) vi.mock('@/api', () => ({ + createPluginInstanceApi: () => mocks.api, pluginApi: mocks.api, default: mocks.api, })) diff --git a/src/components/dialog/__tests__/PluginDataDialog.spec.ts b/src/components/dialog/__tests__/PluginDataDialog.spec.ts index d5ef65df..30eff4dd 100644 --- a/src/components/dialog/__tests__/PluginDataDialog.spec.ts +++ b/src/components/dialog/__tests__/PluginDataDialog.spec.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => { }) vi.mock('@/api', () => ({ + createPluginInstanceApi: () => mocks.api, pluginApi: mocks.api, default: mocks.api, })) diff --git a/src/components/misc/DashboardElement.vue b/src/components/misc/DashboardElement.vue index b83896fb..883e53fc 100644 --- a/src/components/misc/DashboardElement.vue +++ b/src/components/misc/DashboardElement.vue @@ -1,6 +1,6 @@