mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
feat(plugin): scope virtual instance frontend
This commit is contained in:
@@ -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
|
||||
<script setup lang="ts">
|
||||
@@ -298,6 +306,7 @@ const props = defineProps({
|
||||
nativeSubscribe: { type: Function, default: null },
|
||||
navKey: { type: String, default: 'main' },
|
||||
pluginId: { type: String, default: '' },
|
||||
sourcePluginId: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['action'])
|
||||
</script>
|
||||
@@ -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/<sourcePluginId>/...`,也会被映射到实例 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
|
||||
|
||||
@@ -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 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<string, PluginApiClient>()
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// 当前安装实例评分
|
||||
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
|
||||
}
|
||||
|
||||
// 种子信息
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -261,7 +261,6 @@ describe('PluginCard lifecycle actions', () => {
|
||||
suffix: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
icon: string
|
||||
}) => Promise<void>
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
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<void>
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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(() => {
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<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">
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cloneForm.icon"
|
||||
:label="t('plugin.cloneIcon')"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin, RenderProps } from '@/api/types'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
import api, { pluginApi } from '@/api'
|
||||
import api, { createPluginInstanceApi } from '@/api'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import FormRender from '../render/FormRender.vue'
|
||||
import ProgressDialog from '../dialog/ProgressDialog.vue'
|
||||
@@ -62,6 +62,11 @@ provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
const pluginSidebarNavStore = usePluginSidebarNavStore()
|
||||
|
||||
// 联邦插件可继续使用源插件硬编码路径,宿主会把它映射到当前实例。
|
||||
const scopedPluginApi = computed(() =>
|
||||
createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id),
|
||||
)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
|
||||
@@ -240,7 +245,9 @@ onBeforeMount(async () => {
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:initial-config="pluginConfigForm"
|
||||
:api="pluginApi"
|
||||
:api="scopedPluginApi"
|
||||
:plugin-id="props.plugin?.id"
|
||||
:source-plugin-id="props.plugin?.source_plugin_id"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
@save="handleVueComponentSave"
|
||||
@layout="handleVueComponentLayout"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useDisplay } from 'vuetify'
|
||||
import type { Plugin, RenderProps } from '@/api/types'
|
||||
import PageRender from '@/components/render/PageRender.vue'
|
||||
import api, { pluginApi } from '@/api'
|
||||
import api, { createPluginInstanceApi } from '@/api'
|
||||
import { loadRemoteComponent } from '@/utils/federationLoader'
|
||||
import { usePWA } from '@/composables/usePWA'
|
||||
import { useToast } from 'vue-toastification'
|
||||
@@ -47,6 +47,11 @@ provide('moviepilot:confirm', createConfirm)
|
||||
const nativeSubscribe = usePluginNativeSubscribe()
|
||||
provide('moviepilot:nativeSubscribe', nativeSubscribe)
|
||||
|
||||
// 数据页沿用源插件组件,同时把其动态 API 限定到当前实例。
|
||||
const scopedPluginApi = computed(() =>
|
||||
createPluginInstanceApi(props.plugin?.id || '', props.plugin?.source_plugin_id),
|
||||
)
|
||||
|
||||
// 是否刷新
|
||||
const isRefreshed = ref(false)
|
||||
// 只有成功取得合法页面响应时才允许展示空页面状态。
|
||||
@@ -183,7 +188,9 @@ onMounted(() => {
|
||||
<VCardText class="pa-0">
|
||||
<component
|
||||
:is="dynamicComponent"
|
||||
:api="pluginApi"
|
||||
:api="scopedPluginApi"
|
||||
:plugin-id="props.plugin?.id"
|
||||
:source-plugin-id="props.plugin?.source_plugin_id"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
:show_switch="show_switch"
|
||||
@action="handleAction"
|
||||
|
||||
@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => {
|
||||
})
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
createPluginInstanceApi: () => mocks.api,
|
||||
pluginApi: mocks.api,
|
||||
default: mocks.api,
|
||||
}))
|
||||
|
||||
@@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => {
|
||||
})
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
createPluginInstanceApi: () => mocks.api,
|
||||
pluginApi: mocks.api,
|
||||
default: mocks.api,
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { h, resolveComponent } from 'vue'
|
||||
import { pluginApi } from '@/api'
|
||||
import { createPluginInstanceApi } from '@/api'
|
||||
import { DashboardItem } from '@/api/types'
|
||||
import DashboardRender from '@/components/render/DashboardRender.vue'
|
||||
import { isNullOrEmptyObject } from '@/@core/utils'
|
||||
@@ -129,6 +129,11 @@ let dashboardLoadGeneration = 0
|
||||
// 插件UI渲染模式 ('vuetify' 或 'vue')
|
||||
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
|
||||
|
||||
// 仪表盘远程组件共享源码,但所有插件动态 API 保持实例级隔离。
|
||||
const scopedPluginApi = computed(() =>
|
||||
createPluginInstanceApi(props.config?.id || '', props.config?.source_plugin_id),
|
||||
)
|
||||
|
||||
// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
|
||||
const pluginDashboardIdentity = computed(
|
||||
() => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
|
||||
@@ -257,7 +262,9 @@ onUnmounted(() => {
|
||||
:is="dynamicPluginComponent"
|
||||
:config="props.config"
|
||||
:allow-refresh="props.allowRefresh"
|
||||
:api="pluginApi"
|
||||
:api="scopedPluginApi"
|
||||
:plugin-id="props.config?.id"
|
||||
:source-plugin-id="props.config?.source_plugin_id"
|
||||
:native-subscribe="nativeSubscribe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
createPluginInstanceApi: () => ({ get: mocks.apiGet }),
|
||||
default: createDataApiMock({ get: mocks.apiGet }),
|
||||
pluginApi: { get: mocks.apiGet },
|
||||
}))
|
||||
|
||||
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
createPluginInstanceApi: () => mocks.pluginApi,
|
||||
default: mocks.api,
|
||||
pluginApi: mocks.pluginApi,
|
||||
}))
|
||||
|
||||
@@ -15,11 +15,13 @@ const mocks = vi.hoisted(() => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
createPluginInstanceApi: () => mocks.api,
|
||||
default: mocks.api,
|
||||
pluginApi: mocks.api,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/federationLoader', () => ({
|
||||
getRemoteModuleInfo: vi.fn().mockResolvedValue(null),
|
||||
loadRemoteAppPageComponent: (...args: unknown[]) => mocks.loadRemoteAppPageComponent(...args),
|
||||
}))
|
||||
|
||||
|
||||
+12
-2
@@ -4,7 +4,7 @@ import type { Component } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { useAuthStore, useUserStore } from '@/stores'
|
||||
import { authState, userState } from '@/stores/types'
|
||||
import api, { pluginApi } from '@/api'
|
||||
import api, { createPluginInstanceApi, pluginApi } from '@/api'
|
||||
import router from '@/router'
|
||||
import LoginMfaStep from '@/components/auth/LoginMfaStep.vue'
|
||||
import OpticalLogoLab from '@/components/misc/OpticalLogoLab.vue'
|
||||
@@ -181,6 +181,15 @@ function getApiErrorPayload(error: unknown): ApiErrorPayload | undefined {
|
||||
// 登录认证提供方
|
||||
const authProviders = ref<LoginAuthProvider[]>([])
|
||||
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 pluginAuthDialog = ref(false)
|
||||
const pluginAuthLoading = ref(false)
|
||||
@@ -1075,9 +1084,10 @@ onUnmounted(() => {
|
||||
<component
|
||||
v-else-if="RemoteAuthView && selectedAuthProvider"
|
||||
:is="RemoteAuthView"
|
||||
:api="pluginApi"
|
||||
:api="selectedPluginApi"
|
||||
:provider="selectedAuthProvider"
|
||||
:plugin-id="selectedAuthProvider.plugin_id"
|
||||
:source-plugin-id="selectedAuthProvider.remote?.source_plugin_id"
|
||||
@authenticated="handlePluginAuthenticated"
|
||||
@error="handlePluginAuthError"
|
||||
@close="closePluginAuth"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { pluginApi } from '@/api'
|
||||
import { loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||
import { createPluginInstanceApi } from '@/api'
|
||||
import { getRemoteModuleInfo, loadRemoteAppPageComponent } from '@/utils/federationLoader'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
|
||||
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 RemoteView = shallowRef<Component | null>(null)
|
||||
const pluginSourceId = ref<string>()
|
||||
const loadError = ref(false)
|
||||
let loadGeneration = 0
|
||||
|
||||
@@ -37,10 +38,14 @@ watch(
|
||||
const generation = ++loadGeneration
|
||||
loadError.value = false
|
||||
RemoteView.value = null
|
||||
pluginSourceId.value = undefined
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const remoteModule = await getRemoteModuleInfo(pid)
|
||||
if (generation !== loadGeneration) return
|
||||
pluginSourceId.value = remoteModule?.source_plugin_id
|
||||
const remoteView = (await loadRemoteAppPageComponent(pid, nk)) as Component
|
||||
if (generation !== loadGeneration) return
|
||||
RemoteView.value = remoteView
|
||||
@@ -52,6 +57,11 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 路由只携带实例 ID,源身份由联邦发现结果补齐。
|
||||
const scopedPluginApi = computed(() =>
|
||||
createPluginInstanceApi(pluginId.value, pluginSourceId.value),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -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="() => {}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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<RemoteModule | null>
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取远程模块身份信息,供全页宿主构造实例作用域 API。 */
|
||||
export async function getRemoteModuleInfo(id: string): Promise<RemoteModule | null> {
|
||||
return fetchSingleRemoteModule(id)
|
||||
}
|
||||
|
||||
/** 发现并注册尚不可用的远程模块,同一 remote 同时只执行一次。 */
|
||||
async function discoverAndRegisterRemote(id: string): Promise<boolean> {
|
||||
const activeFlight = remoteRegistrationFlights.get(id)
|
||||
|
||||
Reference in New Issue
Block a user