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 @@
@@ -64,10 +74,11 @@ watch(
v-else
:is="RemoteView"
:key="`${pluginId}-${navKey}`"
- :api="pluginApi"
+ :api="scopedPluginApi"
:native-subscribe="nativeSubscribe"
:nav-key="navKey"
:plugin-id="pluginId"
+ :source-plugin-id="pluginSourceId"
@action="() => {}"
/>
diff --git a/src/utils/federationLoader.ts b/src/utils/federationLoader.ts
index 3cf7bb60..4579d390 100644
--- a/src/utils/federationLoader.ts
+++ b/src/utils/federationLoader.ts
@@ -13,6 +13,7 @@ export interface RemoteModule {
id: string
url: string
name?: string
+ source_plugin_id?: string
}
/**
@@ -29,6 +30,11 @@ async function fetchSingleRemoteModule(id: string): Promise
}
}
+/** 获取远程模块身份信息,供全页宿主构造实例作用域 API。 */
+export async function getRemoteModuleInfo(id: string): Promise {
+ return fetchSingleRemoteModule(id)
+}
+
/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */
async function discoverAndRegisterRemote(id: string): Promise {
const activeFlight = remoteRegistrationFlights.get(id)