mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 07:28:37 +08:00
feat(plugin): scope virtual instance frontend
This commit is contained in:
@@ -141,6 +141,8 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
pluginId: { type: String, default: '' },
|
||||||
|
sourcePluginId: { type: String, default: '' },
|
||||||
nativeSubscribe: {
|
nativeSubscribe: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -189,6 +191,8 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
pluginId: { type: String, default: '' },
|
||||||
|
sourcePluginId: { type: String, default: '' },
|
||||||
nativeSubscribe: {
|
nativeSubscribe: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -248,6 +252,9 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
api: { type: Object, default: () => ({}) },
|
||||||
|
pluginId: { type: String, default: '' },
|
||||||
|
sourcePluginId: { type: String, default: '' },
|
||||||
nativeSubscribe: {
|
nativeSubscribe: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null,
|
default: null,
|
||||||
@@ -290,6 +297,7 @@ const props = defineProps({
|
|||||||
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
| `nativeSubscribe` | 打开主应用原生订阅交互 |
|
||||||
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
| `navKey` | 与侧栏声明的 `nav_key` 一致,同一插件多入口时用于区分 |
|
||||||
| `pluginId` | 当前插件 ID |
|
| `pluginId` | 当前插件 ID |
|
||||||
|
| `sourcePluginId` | 虚拟分身共享资源的源插件 ID;普通插件为空 |
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -298,6 +306,7 @@ const props = defineProps({
|
|||||||
nativeSubscribe: { type: Function, default: null },
|
nativeSubscribe: { type: Function, default: null },
|
||||||
navKey: { type: String, default: 'main' },
|
navKey: { type: String, default: 'main' },
|
||||||
pluginId: { type: String, default: '' },
|
pluginId: { type: String, default: '' },
|
||||||
|
sourcePluginId: { type: String, default: '' },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['action'])
|
const emit = defineEmits(['action'])
|
||||||
</script>
|
</script>
|
||||||
@@ -317,6 +326,8 @@ const emit = defineEmits(['action'])
|
|||||||
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
| 能力 | Page | Config | Dashboard | AppPage | 调用方式 |
|
||||||
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
| ---------------- | ---- | ------ | --------- | ------- | ---------------------------------------------------------------- |
|
||||||
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
| 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop |
|
||||||
|
| 当前实例 ID | ✓ | ✓ | ✓ | ✓ | `pluginId` prop |
|
||||||
|
| 共享源码 ID | ✓ | ✓ | ✓ | ✓ | `sourcePluginId` prop;普通插件为空 |
|
||||||
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
| 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` |
|
||||||
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
| 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` |
|
||||||
| 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` |
|
| 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` |
|
||||||
@@ -324,6 +335,11 @@ const emit = defineEmits(['action'])
|
|||||||
|
|
||||||
`nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
`nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。
|
||||||
|
|
||||||
|
V3 新建插件分身会复用源插件的同一份联邦产物。宿主传入的 `api` 已绑定当前
|
||||||
|
`pluginId`:即使旧组件仍调用 `plugin/<sourcePluginId>/...`,也会被映射到实例 API。
|
||||||
|
新组件应直接用 `pluginId` 拼接路径,并始终优先使用 `api` prop;只读取全局
|
||||||
|
`window.MoviePilotAPI` 会绕过实例作用域,不适合多实例插件。
|
||||||
|
|
||||||
### 5.6 玻璃光学表面
|
### 5.6 玻璃光学表面
|
||||||
|
|
||||||
主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
|
主应用的 `Page`、`Config` 与 `AppPage` 宿主在玻璃主题下默认采用 `static-material` 光学模式:保留壁纸透射、材质色调和方向反射,但不响应指针流场、局部折射、拖尾或动态焦散。插件列表与 `Dashboard` 继续使用完整动态光学。视觉型插件可以在自己控制的 DOM 区域显式恢复完整动态光学:
|
||||||
@@ -565,8 +581,8 @@ def get_render_mode() -> Tuple[str, str]:
|
|||||||
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
|
- 需要在插件前端页面调用后端接口时,通过传入的api模块发起调用,后端api接口声明认证类型为:`bear`
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 演示使用api模块调用插件接口
|
// 使用宿主传入的当前实例 ID,普通插件和虚拟分身使用同一份组件代码
|
||||||
recentItems.value = await props.api.get(`plugin/MyPlugin/history`)
|
recentItems.value = await props.api.get(`plugin/${props.pluginId}/history`)
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
+22
-1
@@ -16,6 +16,7 @@ import {
|
|||||||
type DataApiClient,
|
type DataApiClient,
|
||||||
type PluginApiClient,
|
type PluginApiClient,
|
||||||
} from './client'
|
} from './client'
|
||||||
|
import { createScopedPluginApi } from './pluginInstance'
|
||||||
|
|
||||||
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
|
/** 带连接探测和反馈策略的 MoviePilot 请求配置。 */
|
||||||
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
|
||||||
@@ -68,6 +69,7 @@ const { api, pluginApi } = createApiClients({
|
|||||||
},
|
},
|
||||||
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
|
resolveFallbackMessage: key => i18n.global.t(fallbackMessageKeys[key]),
|
||||||
})
|
})
|
||||||
|
const pluginInstanceApis = new Map<string, PluginApiClient>()
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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 解包客户端。
|
// 插件远程组件接收 endpoint 的最终 payload,内部页面默认使用严格 envelope 解包客户端。
|
||||||
if (typeof window !== 'undefined') window.MoviePilotAPI = pluginApi
|
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 type { ApiFeedbackMode, DataApiClient, PluginApiClient }
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -985,6 +985,12 @@ export interface Plugin {
|
|||||||
rating_count?: number
|
rating_count?: number
|
||||||
// 当前安装实例评分
|
// 当前安装实例评分
|
||||||
user_rating?: number | null
|
user_rating?: number | null
|
||||||
|
// 共享代码和联邦资源的源插件 ID
|
||||||
|
source_plugin_id?: string
|
||||||
|
// 是否为共享源码的虚拟实例
|
||||||
|
is_instance?: boolean
|
||||||
|
// 实例实现模式
|
||||||
|
instance_mode?: 'virtual'
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginRuntimeSummary {
|
export interface PluginRuntimeSummary {
|
||||||
@@ -1078,6 +1084,8 @@ export interface DashboardItem {
|
|||||||
elements: RenderProps[]
|
elements: RenderProps[]
|
||||||
// 渲染方式
|
// 渲染方式
|
||||||
render_mode?: string
|
render_mode?: string
|
||||||
|
// 共享联邦资源的源插件 ID
|
||||||
|
source_plugin_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 种子信息
|
// 种子信息
|
||||||
|
|||||||
@@ -480,7 +480,6 @@ async function executePluginClone(cloneForm: {
|
|||||||
suffix: string
|
suffix: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
version: string
|
|
||||||
icon: string
|
icon: string
|
||||||
}) {
|
}) {
|
||||||
if (!cloneForm.suffix.trim()) {
|
if (!cloneForm.suffix.trim()) {
|
||||||
@@ -497,7 +496,6 @@ async function executePluginClone(cloneForm: {
|
|||||||
suffix: cloneForm.suffix.trim(),
|
suffix: cloneForm.suffix.trim(),
|
||||||
name: cloneForm.name.trim(),
|
name: cloneForm.name.trim(),
|
||||||
description: cloneForm.description.trim(),
|
description: cloneForm.description.trim(),
|
||||||
version: cloneForm.version.trim(),
|
|
||||||
icon: cloneForm.icon.trim(),
|
icon: cloneForm.icon.trim(),
|
||||||
},
|
},
|
||||||
{ feedback: 'silent' },
|
{ feedback: 'silent' },
|
||||||
|
|||||||
@@ -261,7 +261,6 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
suffix: string
|
suffix: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
version: string
|
|
||||||
icon: string
|
icon: string
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -269,7 +268,6 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
suffix: ' Test ',
|
suffix: ' Test ',
|
||||||
name: '演示分身',
|
name: '演示分身',
|
||||||
description: ' 独立配置 ',
|
description: ' 独立配置 ',
|
||||||
version: ' 1.0.1 ',
|
|
||||||
icon: ' https://example.com/icon.png ',
|
icon: ' https://example.com/icon.png ',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -277,7 +275,6 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
suffix: 'Test',
|
suffix: 'Test',
|
||||||
name: '演示分身',
|
name: '演示分身',
|
||||||
description: '独立配置',
|
description: '独立配置',
|
||||||
version: '1.0.1',
|
|
||||||
icon: 'https://example.com/icon.png',
|
icon: 'https://example.com/icon.png',
|
||||||
})
|
})
|
||||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
|
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件分身 演示分身 创建成功!')
|
||||||
@@ -296,11 +293,10 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
suffix: string
|
suffix: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
version: string
|
|
||||||
icon: string
|
icon: string
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
}
|
}
|
||||||
await cloneEvents.clone({ suffix: ' ', name: '', description: '', version: '', icon: '' })
|
await cloneEvents.clone({ suffix: ' ', name: '', description: '', icon: '' })
|
||||||
|
|
||||||
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
|
expect(mocks.toastError).toHaveBeenCalledWith('分身后缀不能为空')
|
||||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||||
@@ -316,11 +312,10 @@ describe('PluginCard lifecycle actions', () => {
|
|||||||
suffix: string
|
suffix: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
version: string
|
|
||||||
icon: string
|
icon: string
|
||||||
}) => Promise<void>
|
}) => Promise<void>
|
||||||
}
|
}
|
||||||
const form = { suffix: 'Test', name: '测试', description: '', version: '', icon: '' }
|
const form = { suffix: 'Test', name: '测试', description: '', icon: '' }
|
||||||
await cloneEvents.clone(form)
|
await cloneEvents.clone(form)
|
||||||
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
|
expect(mocks.toastError).toHaveBeenCalledWith('插件分身创建失败:后缀已存在')
|
||||||
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
expect(mocks.dialogCloses[0]).not.toHaveBeenCalled()
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ const cloneForm = ref({
|
|||||||
suffix: '',
|
suffix: '',
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
version: '',
|
|
||||||
icon: '',
|
icon: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ function initializeCloneForm() {
|
|||||||
suffix: '',
|
suffix: '',
|
||||||
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
|
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
|
||||||
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
|
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
|
||||||
version: props.plugin?.plugin_version || '1.0',
|
|
||||||
icon: props.plugin?.plugin_icon || '',
|
icon: props.plugin?.plugin_icon || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,18 +119,7 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</VCol>
|
</VCol>
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12">
|
||||||
<VTextField
|
|
||||||
v-model="cloneForm.version"
|
|
||||||
:label="t('plugin.cloneVersion')"
|
|
||||||
:placeholder="t('plugin.cloneVersionPlaceholder')"
|
|
||||||
:hint="t('plugin.cloneVersionHint')"
|
|
||||||
persistent-hint
|
|
||||||
prepend-inner-icon="mdi-numeric"
|
|
||||||
/>
|
|
||||||
</VCol>
|
|
||||||
|
|
||||||
<VCol cols="12" md="6">
|
|
||||||
<VTextField
|
<VTextField
|
||||||
v-model="cloneForm.icon"
|
v-model="cloneForm.icon"
|
||||||
:label="t('plugin.cloneIcon')"
|
:label="t('plugin.cloneIcon')"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import type { Plugin, RenderProps } from '@/api/types'
|
import type { Plugin, RenderProps } from '@/api/types'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
import api, { pluginApi } from '@/api'
|
import api, { createPluginInstanceApi } from '@/api'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import FormRender from '../render/FormRender.vue'
|
import FormRender from '../render/FormRender.vue'
|
||||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||||
@@ -62,6 +62,11 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
|||||||
|
|
||||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||||
|
|
||||||
|
// 联邦插件可继续使用源插件硬编码路径,宿主会把它映射到当前实例。
|
||||||
|
const scopedPluginApi = computed(() =>
|
||||||
|
createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id),
|
||||||
|
)
|
||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
|
|
||||||
@@ -240,7 +245,9 @@ onBeforeMount(async () => {
|
|||||||
<component
|
<component
|
||||||
:is="dynamicComponent"
|
:is="dynamicComponent"
|
||||||
:initial-config="pluginConfigForm"
|
:initial-config="pluginConfigForm"
|
||||||
:api="pluginApi"
|
:api="scopedPluginApi"
|
||||||
|
:plugin-id="props.plugin?.id"
|
||||||
|
:source-plugin-id="props.plugin?.source_plugin_id"
|
||||||
:native-subscribe="nativeSubscribe"
|
:native-subscribe="nativeSubscribe"
|
||||||
@save="handleVueComponentSave"
|
@save="handleVueComponentSave"
|
||||||
@layout="handleVueComponentLayout"
|
@layout="handleVueComponentLayout"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import type { Plugin, RenderProps } from '@/api/types'
|
import type { Plugin, RenderProps } from '@/api/types'
|
||||||
import PageRender from '@/components/render/PageRender.vue'
|
import PageRender from '@/components/render/PageRender.vue'
|
||||||
import api, { pluginApi } from '@/api'
|
import api, { createPluginInstanceApi } from '@/api'
|
||||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||||
import { usePWA } from '@/composables/usePWA'
|
import { usePWA } from '@/composables/usePWA'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
@@ -47,6 +47,11 @@ provide('moviepilot:confirm', createConfirm)
|
|||||||
const nativeSubscribe = usePluginNativeSubscribe()
|
const nativeSubscribe = usePluginNativeSubscribe()
|
||||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||||
|
|
||||||
|
// 数据页沿用源插件组件,同时把其动态 API 限定到当前实例。
|
||||||
|
const scopedPluginApi = computed(() =>
|
||||||
|
createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id),
|
||||||
|
)
|
||||||
|
|
||||||
// 是否刷新
|
// 是否刷新
|
||||||
const isRefreshed = ref(false)
|
const isRefreshed = ref(false)
|
||||||
// 只有成功取得合法页面响应时才允许展示空页面状态。
|
// 只有成功取得合法页面响应时才允许展示空页面状态。
|
||||||
@@ -183,7 +188,9 @@ onMounted(() => {
|
|||||||
<VCardText class="pa-0">
|
<VCardText class="pa-0">
|
||||||
<component
|
<component
|
||||||
:is="dynamicComponent"
|
:is="dynamicComponent"
|
||||||
:api="pluginApi"
|
:api="scopedPluginApi"
|
||||||
|
:plugin-id="props.plugin?.id"
|
||||||
|
:source-plugin-id="props.plugin?.source_plugin_id"
|
||||||
:native-subscribe="nativeSubscribe"
|
:native-subscribe="nativeSubscribe"
|
||||||
:show_switch="show_switch"
|
:show_switch="show_switch"
|
||||||
@action="handleAction"
|
@action="handleAction"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
|
createPluginInstanceApi: () => mocks.api,
|
||||||
pluginApi: mocks.api,
|
pluginApi: mocks.api,
|
||||||
default: mocks.api,
|
default: mocks.api,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
|
createPluginInstanceApi: () => mocks.api,
|
||||||
pluginApi: mocks.api,
|
pluginApi: mocks.api,
|
||||||
default: mocks.api,
|
default: mocks.api,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { h, resolveComponent } from 'vue'
|
import { h, resolveComponent } from 'vue'
|
||||||
import { pluginApi } from '@/api'
|
import { createPluginInstanceApi } from '@/api'
|
||||||
import { DashboardItem } from '@/api/types'
|
import { DashboardItem } from '@/api/types'
|
||||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||||
@@ -129,6 +129,11 @@ let dashboardLoadGeneration = 0
|
|||||||
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
||||||
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
||||||
|
|
||||||
|
// 仪表盘远程组件共享源码,但所有插件动态 API 保持实例级隔离。
|
||||||
|
const scopedPluginApi = computed(() =>
|
||||||
|
createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id),
|
||||||
|
)
|
||||||
|
|
||||||
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
|
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
|
||||||
const pluginDashboardIdentity = computed(
|
const pluginDashboardIdentity = computed(
|
||||||
() => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
|
() => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
|
||||||
@@ -257,7 +262,9 @@ onUnmounted(() => {
|
|||||||
:is="dynamicPluginComponent"
|
:is="dynamicPluginComponent"
|
||||||
:config="props.config"
|
:config="props.config"
|
||||||
:allow-refresh="props.allowRefresh"
|
:allow-refresh="props.allowRefresh"
|
||||||
:api="pluginApi"
|
:api="scopedPluginApi"
|
||||||
|
:plugin-id="props.config?.id"
|
||||||
|
:source-plugin-id="props.config?.source_plugin_id"
|
||||||
:native-subscribe="nativeSubscribe"
|
:native-subscribe="nativeSubscribe"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
|
createPluginInstanceApi: () => ({ get: mocks.apiGet }),
|
||||||
default: createDataApiMock({ get: mocks.apiGet }),
|
default: createDataApiMock({ get: mocks.apiGet }),
|
||||||
pluginApi: { get: mocks.apiGet },
|
pluginApi: { get: mocks.apiGet },
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
|
createPluginInstanceApi: () => mocks.pluginApi,
|
||||||
default: mocks.api,
|
default: mocks.api,
|
||||||
pluginApi: mocks.pluginApi,
|
pluginApi: mocks.pluginApi,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/api', () => ({
|
vi.mock('@/api', () => ({
|
||||||
|
createPluginInstanceApi: () => mocks.api,
|
||||||
default: mocks.api,
|
default: mocks.api,
|
||||||
pluginApi: mocks.api,
|
pluginApi: mocks.api,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/federationLoader', () => ({
|
vi.mock('@/utils/federationLoader', () => ({
|
||||||
|
getRemoteModuleInfo: vi.fn().mockResolvedValue(null),
|
||||||
loadRemoteAppPageComponent: (...args: unknown[]) => mocks.loadRemoteAppPageComponent(...args),
|
loadRemoteAppPageComponent: (...args: unknown[]) => mocks.loadRemoteAppPageComponent(...args),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|||||||
+12
-2
@@ -4,7 +4,7 @@ import type { Component } from 'vue'
|
|||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { useAuthStore, useUserStore } from '@/stores'
|
import { useAuthStore, useUserStore } from '@/stores'
|
||||||
import { authState, userState } from '@/stores/types'
|
import { authState, userState } from '@/stores/types'
|
||||||
import api, { pluginApi } from '@/api'
|
import api, { createPluginInstanceApi, pluginApi } from '@/api'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
||||||
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
|
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
|
||||||
@@ -181,6 +181,15 @@ function getApiErrorPayload(error: unknown): ApiErrorPayload | undefined {
|
|||||||
// 登录认证提供方
|
// 登录认证提供方
|
||||||
const authProviders = ref<LoginAuthProvider[]>([])
|
const authProviders = ref<LoginAuthProvider[]>([])
|
||||||
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
|
const selectedAuthProvider = ref<LoginAuthProvider | null>(null)
|
||||||
|
|
||||||
|
// 登录插件也使用实例作用域客户端;系统内置登录请求仍使用全局插件客户端。
|
||||||
|
const selectedPluginApi = computed(() => {
|
||||||
|
const pluginId = selectedAuthProvider.value?.plugin_id || ''
|
||||||
|
return createPluginInstanceApi(
|
||||||
|
pluginId,
|
||||||
|
selectedAuthProvider.value?.remote?.source_plugin_id,
|
||||||
|
)
|
||||||
|
})
|
||||||
const RemoteAuthView = shallowRef<Component | null>(null)
|
const RemoteAuthView = shallowRef<Component | null>(null)
|
||||||
const pluginAuthDialog = ref(false)
|
const pluginAuthDialog = ref(false)
|
||||||
const pluginAuthLoading = ref(false)
|
const pluginAuthLoading = ref(false)
|
||||||
@@ -1075,9 +1084,10 @@ onUnmounted(() => {
|
|||||||
<component
|
<component
|
||||||
v-else-if="RemoteAuthView && selectedAuthProvider"
|
v-else-if="RemoteAuthView && selectedAuthProvider"
|
||||||
:is="RemoteAuthView"
|
:is="RemoteAuthView"
|
||||||
:api="pluginApi"
|
:api="selectedPluginApi"
|
||||||
:provider="selectedAuthProvider"
|
:provider="selectedAuthProvider"
|
||||||
:plugin-id="selectedAuthProvider.plugin_id"
|
:plugin-id="selectedAuthProvider.plugin_id"
|
||||||
|
:source-plugin-id="selectedAuthProvider.remote?.source_plugin_id"
|
||||||
@authenticated="handlePluginAuthenticated"
|
@authenticated="handlePluginAuthenticated"
|
||||||
@error="handlePluginAuthError"
|
@error="handlePluginAuthError"
|
||||||
@close="closePluginAuth"
|
@close="closePluginAuth"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { pluginApi } from '@/api'
|
import { createPluginInstanceApi } from '@/api'
|
||||||
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
import { getRemoteModuleInfo, loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
@@ -13,6 +13,7 @@ const pluginId = computed(() => route.params.pluginId as string)
|
|||||||
const navKey = computed(() => (route.params.navKey as string) || 'main')
|
const navKey = computed(() => (route.params.navKey as string) || 'main')
|
||||||
|
|
||||||
const RemoteView = shallowRef<Component | null>(null)
|
const RemoteView = shallowRef<Component | null>(null)
|
||||||
|
const pluginSourceId = ref<string>()
|
||||||
const loadError = ref(false)
|
const loadError = ref(false)
|
||||||
let loadGeneration = 0
|
let loadGeneration = 0
|
||||||
|
|
||||||
@@ -37,10 +38,14 @@ watch(
|
|||||||
const generation = ++loadGeneration
|
const generation = ++loadGeneration
|
||||||
loadError.value = false
|
loadError.value = false
|
||||||
RemoteView.value = null
|
RemoteView.value = null
|
||||||
|
pluginSourceId.value = undefined
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const remoteModule = await getRemoteModuleInfo(pid)
|
||||||
|
if (generation !== loadGeneration) return
|
||||||
|
pluginSourceId.value = remoteModule?.source_plugin_id
|
||||||
const remoteView = (await loadRemoteAppPageComponent(pid, nk)) as Component
|
const remoteView = (await loadRemoteAppPageComponent(pid, nk)) as Component
|
||||||
if (generation !== loadGeneration) return
|
if (generation !== loadGeneration) return
|
||||||
RemoteView.value = remoteView
|
RemoteView.value = remoteView
|
||||||
@@ -52,6 +57,11 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 路由只携带实例 ID,源身份由联邦发现结果补齐。
|
||||||
|
const scopedPluginApi = computed(() =>
|
||||||
|
createPluginInstanceApi(pluginId.value, pluginSourceId.value),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -64,10 +74,11 @@ watch(
|
|||||||
v-else
|
v-else
|
||||||
:is="RemoteView"
|
:is="RemoteView"
|
||||||
:key="`${pluginId}-${navKey}`"
|
:key="`${pluginId}-${navKey}`"
|
||||||
:api="pluginApi"
|
:api="scopedPluginApi"
|
||||||
:native-subscribe="nativeSubscribe"
|
:native-subscribe="nativeSubscribe"
|
||||||
:nav-key="navKey"
|
:nav-key="navKey"
|
||||||
:plugin-id="pluginId"
|
:plugin-id="pluginId"
|
||||||
|
:source-plugin-id="pluginSourceId"
|
||||||
@action="() => {}"
|
@action="() => {}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface RemoteModule {
|
|||||||
id: string
|
id: string
|
||||||
url: string
|
url: string
|
||||||
name?: string
|
name?: string
|
||||||
|
source_plugin_id?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +30,11 @@ async function fetchSingleRemoteModule(id: string): Promise<RemoteModule | null>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取远程模块身份信息,供全页宿主构造实例作用域 API。 */
|
||||||
|
export async function getRemoteModuleInfo(id: string): Promise<RemoteModule | null> {
|
||||||
|
return fetchSingleRemoteModule(id)
|
||||||
|
}
|
||||||
|
|
||||||
/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */
|
/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */
|
||||||
async function discoverAndRegisterRemote(id: string): Promise<boolean> {
|
async function discoverAndRegisterRemote(id: string): Promise<boolean> {
|
||||||
const activeFlight = remoteRegistrationFlights.get(id)
|
const activeFlight = remoteRegistrationFlights.get(id)
|
||||||
|
|||||||
Reference in New Issue
Block a user