diff --git a/src/components/dialog/__tests__/PluginConfigDialog.spec.ts b/src/components/dialog/__tests__/PluginConfigDialog.spec.ts
new file mode 100644
index 00000000..44fdcd33
--- /dev/null
+++ b/src/components/dialog/__tests__/PluginConfigDialog.spec.ts
@@ -0,0 +1,292 @@
+import type { Plugin } from '@/api/types'
+import PluginConfigDialog from '@/components/dialog/PluginConfigDialog.vue'
+import { renderWithProviders } from '@tests/support/render'
+import { fireEvent, screen, waitFor } from '@testing-library/vue'
+import { defineComponent, h, inject, type Component, type PropType } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ apiGet: vi.fn(),
+ apiPut: vi.fn(),
+ ensureSidebarNav: vi.fn(),
+ loadRemoteComponent: vi.fn(),
+ nativeSubscribe: vi.fn(),
+ toast: { error: vi.fn(), success: vi.fn() },
+}))
+
+vi.mock('@/api', () => ({
+ default: {
+ get: mocks.apiGet,
+ put: mocks.apiPut,
+ },
+}))
+
+vi.mock('@/utils/federationLoader', () => ({
+ loadRemoteComponent: mocks.loadRemoteComponent,
+}))
+
+vi.mock('@/composables/usePluginNativeSubscribe', () => ({
+ usePluginNativeSubscribe: () => mocks.nativeSubscribe,
+}))
+
+vi.mock('@/stores/pluginSidebarNav', () => ({
+ usePluginSidebarNavStore: () => ({ ensureSidebarNav: mocks.ensureSidebarNav }),
+}))
+
+vi.mock('vue-toastification', () => ({
+ useToast: () => mocks.toast,
+}))
+
+const plugin: Plugin = {
+ has_page: true,
+ id: 'DemoPlugin',
+ plugin_name: '演示插件',
+}
+
+const DialogStub = defineComponent({
+ name: 'VDialog',
+ props: { maxWidth: String },
+ setup:
+ (props, { slots }) =>
+ () =>
+ h('section', { 'data-max-width': props.maxWidth, role: 'dialog' }, slots.default?.()),
+})
+
+const LoadingBannerStub = defineComponent({
+ name: 'LoadingBanner',
+ setup: () => () => h('div', '正在加载'),
+})
+
+const ProgressDialogStub = defineComponent({
+ name: 'ProgressDialog',
+ props: { text: String },
+ setup: props => () => h('div', { 'data-testid': 'save-progress' }, props.text),
+})
+
+const CloseButtonStub = defineComponent({
+ name: 'VDialogCloseBtn',
+ setup:
+ (_, { attrs }) =>
+ () =>
+ h('button', { ...attrs, 'aria-label': '关闭', type: 'button' }),
+})
+
+const FormRenderStub = defineComponent({
+ name: 'FormRender',
+ props: {
+ config: { type: Object as PropType
>, required: true },
+ model: { type: Object as PropType>, required: true },
+ },
+ setup: props => () => h('div', { 'data-testid': 'form-render' }, `${props.config.component}:${props.model.enabled}`),
+})
+
+type RemoteCapture = {
+ api: unknown
+ initialConfig: Record
+ injectedNativeSubscribe: unknown
+ injectedToast: unknown
+ nativeSubscribe: unknown
+}
+
+function createDeferred() {
+ let reject!: (reason?: unknown) => void
+ let resolve!: (value: T | PromiseLike) => void
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve
+ reject = promiseReject
+ })
+
+ return { promise, reject, resolve }
+}
+
+function createRemoteConfig(captures: RemoteCapture[]): Component {
+ return defineComponent({
+ name: 'RemoteConfigFixture',
+ props: {
+ api: Object,
+ initialConfig: { type: Object as PropType>, required: true },
+ nativeSubscribe: Function,
+ },
+ emits: ['close', 'layout', 'save', 'switch'],
+ setup(props, { emit }) {
+ captures.push({
+ api: props.api,
+ initialConfig: props.initialConfig,
+ injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
+ injectedToast: inject('moviepilot:toast'),
+ nativeSubscribe: props.nativeSubscribe,
+ })
+ return () =>
+ h('section', { 'data-testid': 'remote-config' }, [
+ h('button', { onClick: () => emit('layout', { maxWidth: '72rem' }), type: 'button' }, '调整布局'),
+ h('button', { onClick: () => emit('save', { enabled: false }), type: 'button' }, '远程保存'),
+ h('button', { onClick: () => emit('switch'), type: 'button' }, '切换数据'),
+ h('button', { onClick: () => emit('close'), type: 'button' }, '关闭远程配置'),
+ ])
+ },
+ })
+}
+
+async function renderDialog() {
+ return renderWithProviders(PluginConfigDialog, {
+ props: { modelValue: true, plugin },
+ global: {
+ stubs: {
+ FormRender: FormRenderStub,
+ LoadingBanner: LoadingBannerStub,
+ ProgressDialog: ProgressDialogStub,
+ VDialog: DialogStub,
+ VDialogCloseBtn: CloseButtonStub,
+ },
+ },
+ })
+}
+
+describe('PluginConfigDialog', () => {
+ beforeEach(() => {
+ mocks.apiGet.mockReset()
+ mocks.apiPut.mockReset()
+ mocks.ensureSidebarNav.mockReset().mockResolvedValue(undefined)
+ mocks.loadRemoteComponent.mockReset()
+ mocks.nativeSubscribe.mockReset()
+ mocks.toast.error.mockReset()
+ mocks.toast.success.mockReset()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+
+ it('distinguishes an empty Vuetify form from a failed load and retries in place', async () => {
+ mocks.apiGet.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({
+ conf: [],
+ model: {},
+ render_mode: 'vuetify',
+ })
+ mocks.apiPut.mockResolvedValue({ success: true })
+
+ await renderDialog()
+
+ expect(await screen.findByText('插件配置加载失败,请稍后重试')).toBeInTheDocument()
+ expect(screen.queryByText('此插件没有可配置项')).not.toBeInTheDocument()
+
+ await fireEvent.click(screen.getByRole('button', { name: '重试' }))
+
+ expect(await screen.findByText('此插件没有可配置项')).toBeInTheDocument()
+ expect(mocks.apiGet).toHaveBeenCalledTimes(2)
+
+ await fireEvent.click(screen.getByRole('button', { name: '保存' }))
+ await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', {}))
+ })
+
+ it('does not expose configuration saving while the form is loading or failed', async () => {
+ const load = createDeferred()
+ mocks.apiGet.mockReturnValue(load.promise)
+
+ await renderDialog()
+
+ expect(screen.getByText('正在加载')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
+
+ load.reject(new Error('request failed'))
+
+ expect(await screen.findByText('插件配置加载失败,请稍后重试')).toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument()
+ expect(mocks.apiPut).not.toHaveBeenCalled()
+ })
+
+ it('renders the Vuetify form with the merged model returned by the backend', async () => {
+ mocks.apiGet.mockResolvedValue({
+ conf: [{ component: 'VSwitch', props: { label: '启用' } }],
+ model: { enabled: true },
+ render_mode: 'vuetify',
+ })
+
+ const result = await renderDialog()
+
+ expect(await screen.findByTestId('form-render')).toHaveTextContent('VSwitch:true')
+ await fireEvent.click(screen.getByRole('button', { name: '查看数据' }))
+ await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
+ expect(result.emitted().switch).toHaveLength(1)
+ expect(result.emitted().close).toHaveLength(1)
+ })
+
+ it('passes the same host capabilities through props and provide and forwards remote events', async () => {
+ const captures: RemoteCapture[] = []
+ mocks.apiGet.mockResolvedValue({ model: { enabled: true }, render_mode: 'vue' })
+ mocks.apiPut.mockResolvedValue({ success: true })
+ mocks.loadRemoteComponent.mockResolvedValue(createRemoteConfig(captures))
+
+ const result = await renderDialog()
+
+ expect(await screen.findByTestId('remote-config')).toBeInTheDocument()
+ expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Config')
+ expect(captures).toHaveLength(1)
+ expect(captures[0]).toMatchObject({ initialConfig: { enabled: true } })
+ expect(captures[0].api).toMatchObject({ get: mocks.apiGet, put: mocks.apiPut })
+ expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
+ expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
+ expect(captures[0].injectedToast).toBe(mocks.toast)
+
+ await fireEvent.click(screen.getByRole('button', { name: '调整布局' }))
+ expect(screen.getByRole('dialog')).toHaveAttribute('data-max-width', '72rem')
+ await fireEvent.click(screen.getByRole('button', { name: '远程保存' }))
+ await waitFor(() => expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: false }))
+ await fireEvent.click(screen.getByRole('button', { name: '切换数据' }))
+ await fireEvent.click(screen.getByRole('button', { name: '关闭远程配置' }))
+
+ expect(result.emitted().switch).toHaveLength(1)
+ expect(result.emitted().close).toHaveLength(1)
+ })
+
+ it('renders the async error component when the remote Config rejects', async () => {
+ mocks.apiGet.mockResolvedValue({ model: {}, render_mode: 'vue' })
+ mocks.loadRemoteComponent.mockRejectedValue(new Error('remote unavailable'))
+
+ await renderDialog()
+
+ expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
+ })
+
+ it('renders the precompiled loading state while the remote Config is pending', async () => {
+ const remote = createDeferred()
+ mocks.apiGet.mockResolvedValue({ model: {}, render_mode: 'vue' })
+ mocks.loadRemoteComponent.mockReturnValue(remote.promise)
+
+ await renderDialog()
+
+ expect(await screen.findByTestId('remote-component-loading')).toBeInTheDocument()
+ })
+
+ it('keeps configuration save successful when the sidebar refresh fails', async () => {
+ mocks.apiGet.mockResolvedValue({ conf: [], model: { enabled: true }, render_mode: 'vuetify' })
+ mocks.apiPut.mockResolvedValue({ success: true })
+ mocks.ensureSidebarNav.mockRejectedValue(new Error('sidebar unavailable'))
+
+ const result = await renderDialog()
+ await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
+
+ await waitFor(() => {
+ expect(mocks.apiPut).toHaveBeenCalledWith('plugin/DemoPlugin', { enabled: true })
+ expect(mocks.ensureSidebarNav).toHaveBeenCalledWith(true)
+ expect(result.emitted().save).toHaveLength(1)
+ })
+ expect(mocks.toast.success).toHaveBeenCalledOnce()
+ expect(mocks.toast.error).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('save-progress')).not.toBeInTheDocument()
+ })
+
+ it.each([
+ ['business failure', () => Promise.resolve({ message: '配置被拒绝', success: false })],
+ ['HTTP failure', () => Promise.reject(new Error('request failed'))],
+ ])('keeps the dialog open and reports a %s', async (_case, saveResult) => {
+ mocks.apiGet.mockResolvedValue({ conf: [], model: {}, render_mode: 'vuetify' })
+ mocks.apiPut.mockImplementation(saveResult)
+
+ const result = await renderDialog()
+ await fireEvent.click(await screen.findByRole('button', { name: '保存' }))
+
+ await waitFor(() => expect(mocks.toast.error).toHaveBeenCalledOnce())
+ expect(result.emitted().save).toBeUndefined()
+ expect(mocks.ensureSidebarNav).not.toHaveBeenCalled()
+ expect(screen.queryByTestId('save-progress')).not.toBeInTheDocument()
+ })
+})
diff --git a/src/components/dialog/__tests__/PluginDataDialog.spec.ts b/src/components/dialog/__tests__/PluginDataDialog.spec.ts
new file mode 100644
index 00000000..5f87beb0
--- /dev/null
+++ b/src/components/dialog/__tests__/PluginDataDialog.spec.ts
@@ -0,0 +1,236 @@
+import type { Plugin } from '@/api/types'
+import PluginDataDialog from '@/components/dialog/PluginDataDialog.vue'
+import { renderWithProviders } from '@tests/support/render'
+import { fireEvent, screen, waitFor } from '@testing-library/vue'
+import { defineComponent, h, inject, type Component, type PropType } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ apiGet: vi.fn(),
+ loadRemoteComponent: vi.fn(),
+ nativeSubscribe: vi.fn(),
+ toast: { error: vi.fn(), success: vi.fn() },
+}))
+
+vi.mock('@/api', () => ({
+ default: { get: mocks.apiGet },
+}))
+
+vi.mock('@/utils/federationLoader', () => ({
+ loadRemoteComponent: mocks.loadRemoteComponent,
+}))
+
+vi.mock('@/composables/usePluginNativeSubscribe', () => ({
+ usePluginNativeSubscribe: () => mocks.nativeSubscribe,
+}))
+
+vi.mock('@/composables/usePWA', () => ({
+ usePWA: () => ({ appMode: false }),
+}))
+
+vi.mock('vue-toastification', () => ({
+ useToast: () => mocks.toast,
+}))
+
+const plugin: Plugin = {
+ id: 'DemoPlugin',
+ plugin_name: '演示插件',
+}
+
+const DialogStub = defineComponent({
+ name: 'VDialog',
+ setup:
+ (_, { slots }) =>
+ () =>
+ h('section', { role: 'dialog' }, slots.default?.()),
+})
+
+const LoadingBannerStub = defineComponent({
+ name: 'LoadingBanner',
+ setup: () => () => h('div', '正在加载'),
+})
+
+const CloseButtonStub = defineComponent({
+ name: 'VDialogCloseBtn',
+ setup:
+ (_, { attrs }) =>
+ () =>
+ h('button', { ...attrs, 'aria-label': '关闭', type: 'button' }),
+})
+
+const FabStub = defineComponent({
+ name: 'VFab',
+ setup:
+ (_, { attrs }) =>
+ () =>
+ h('button', { ...attrs, 'aria-label': '切换配置', type: 'button' }),
+})
+
+const PageRenderStub = defineComponent({
+ name: 'PageRender',
+ props: {
+ config: { type: Object as PropType>, required: true },
+ },
+ emits: ['action'],
+ setup:
+ (props, { emit }) =>
+ () =>
+ h('button', { onClick: () => emit('action'), type: 'button' }, String(props.config.component)),
+})
+
+type RemoteCapture = {
+ api: unknown
+ injectedNativeSubscribe: unknown
+ injectedToast: unknown
+ nativeSubscribe: unknown
+ showSwitch: boolean
+}
+
+function createDeferred() {
+ let resolve!: (value: T | PromiseLike) => void
+ const promise = new Promise(promiseResolve => {
+ resolve = promiseResolve
+ })
+
+ return { promise, resolve }
+}
+
+function createRemotePage(captures: RemoteCapture[]): Component {
+ return defineComponent({
+ name: 'RemotePageFixture',
+ props: {
+ api: Object,
+ nativeSubscribe: Function,
+ show_switch: Boolean,
+ },
+ emits: ['action', 'close', 'switch'],
+ setup(props, { emit }) {
+ captures.push({
+ api: props.api,
+ injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
+ injectedToast: inject('moviepilot:toast'),
+ nativeSubscribe: props.nativeSubscribe,
+ showSwitch: props.show_switch,
+ })
+ return () =>
+ h('section', { 'data-testid': 'remote-page' }, [
+ h('button', { onClick: () => emit('action'), type: 'button' }, '刷新远程页面'),
+ h('button', { onClick: () => emit('switch'), type: 'button' }, '切换配置'),
+ h('button', { onClick: () => emit('close'), type: 'button' }, '关闭远程页面'),
+ ])
+ },
+ })
+}
+
+async function renderDialog(showSwitch = true) {
+ return renderWithProviders(PluginDataDialog, {
+ props: { modelValue: true, plugin, show_switch: showSwitch },
+ global: {
+ stubs: {
+ LoadingBanner: LoadingBannerStub,
+ PageRender: PageRenderStub,
+ VDialog: DialogStub,
+ VDialogCloseBtn: CloseButtonStub,
+ VFab: FabStub,
+ },
+ },
+ })
+}
+
+describe('PluginDataDialog', () => {
+ beforeEach(() => {
+ mocks.apiGet.mockReset()
+ mocks.loadRemoteComponent.mockReset()
+ mocks.nativeSubscribe.mockReset()
+ mocks.toast.error.mockReset()
+ mocks.toast.success.mockReset()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+
+ it('distinguishes an empty Vuetify page from a failed load and retries in place', async () => {
+ mocks.apiGet.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({
+ page: [],
+ render_mode: 'vuetify',
+ })
+
+ await renderDialog()
+
+ expect(await screen.findByText('插件数据加载失败,请稍后重试')).toBeInTheDocument()
+ expect(screen.queryByText('此插件没有详情页面')).not.toBeInTheDocument()
+
+ await fireEvent.click(screen.getByRole('button', { name: '重试' }))
+
+ expect(await screen.findByText('此插件没有详情页面')).toBeInTheDocument()
+ expect(mocks.apiGet).toHaveBeenCalledTimes(2)
+ })
+
+ it('renders Vuetify page definitions and reloads them after an action', async () => {
+ mocks.apiGet
+ .mockResolvedValueOnce({ page: [{ component: 'VBtn' }], render_mode: 'vuetify' })
+ .mockResolvedValueOnce({ page: [{ component: 'VChip' }], render_mode: 'vuetify' })
+
+ const result = await renderDialog()
+
+ await fireEvent.click(await screen.findByRole('button', { name: 'VBtn' }))
+ expect(await screen.findByRole('button', { name: 'VChip' })).toBeInTheDocument()
+ await fireEvent.click(screen.getByRole('button', { name: '切换配置' }))
+ await fireEvent.click(screen.getByRole('button', { name: '关闭' }))
+ expect(mocks.apiGet).toHaveBeenCalledTimes(2)
+ expect(result.emitted().switch).toHaveLength(1)
+ expect(result.emitted().close).toHaveLength(1)
+ })
+
+ it('passes the same host capabilities through props and provide and forwards remote events', async () => {
+ const captures: RemoteCapture[] = []
+ mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
+ mocks.loadRemoteComponent.mockResolvedValue(createRemotePage(captures))
+
+ const result = await renderDialog(false)
+
+ expect(await screen.findByTestId('remote-page')).toBeInTheDocument()
+ expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Page')
+ expect(captures).toHaveLength(1)
+ expect(captures[0].api).toMatchObject({ get: mocks.apiGet })
+ expect(captures[0].showSwitch).toBe(false)
+ expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
+ expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
+ expect(captures[0].injectedToast).toBe(mocks.toast)
+
+ await fireEvent.click(screen.getByRole('button', { name: '刷新远程页面' }))
+ await fireEvent.click(screen.getByRole('button', { name: '切换配置' }))
+ await fireEvent.click(screen.getByRole('button', { name: '关闭远程页面' }))
+
+ expect(mocks.apiGet).toHaveBeenCalledOnce()
+ expect(result.emitted().switch).toHaveLength(1)
+ expect(result.emitted().close).toHaveLength(1)
+ })
+
+ it('renders the async error component when the remote Page rejects', async () => {
+ mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
+ mocks.loadRemoteComponent.mockRejectedValue(new Error('remote unavailable'))
+
+ await renderDialog()
+
+ expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
+ })
+
+ it('renders the precompiled loading state while the remote Page is pending', async () => {
+ const remote = createDeferred()
+ mocks.apiGet.mockResolvedValue({ render_mode: 'vue' })
+ mocks.loadRemoteComponent.mockReturnValue(remote.promise)
+
+ await renderDialog()
+
+ expect(await screen.findByTestId('remote-component-loading')).toBeInTheDocument()
+ })
+
+ it('treats an unsupported render mode as a load error instead of a blank dialog', async () => {
+ mocks.apiGet.mockResolvedValue({ render_mode: 'legacy' })
+
+ await renderDialog()
+
+ expect(await screen.findByText('插件数据加载失败,请稍后重试')).toBeInTheDocument()
+ await waitFor(() => expect(screen.queryByText('此插件没有详情页面')).not.toBeInTheDocument())
+ })
+})
diff --git a/src/components/misc/DashboardElement.vue b/src/components/misc/DashboardElement.vue
index e4d6ec7e..8165f249 100644
--- a/src/components/misc/DashboardElement.vue
+++ b/src/components/misc/DashboardElement.vue
@@ -7,6 +7,7 @@ import { isNullOrEmptyObject } from '@/@core/utils'
import { loadRemoteComponent } from '@/utils/federationLoader'
import { useToast } from 'vue-toastification'
import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe'
+import RemoteComponentError from './RemoteComponentError.vue'
type DashboardComponentLoader = () => Promise
@@ -114,50 +115,50 @@ const isDashboardElementLoaded = ref(false)
let isDashboardElementUnmounted = false
let pluginDashboardComponentLoadPromise: Promise | null = null
+let dashboardLoadGeneration = 0
// 插件UI渲染模式 ('vuetify' 或 'vue')
const pluginRenderMode = computed(() => props.config?.render_mode || 'vuetify')
-// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
-function loadPluginDashboardComponent() {
- if (!props.config?.id) return Promise.reject(new Error('插件ID不存在'))
+// 插件节点身份变化时重建异步组件,使失败后的远程模块可以再次加载。
+const pluginDashboardIdentity = computed(
+ () => `${props.config?.id ?? ''}:${props.config?.key ?? ''}:${pluginRenderMode.value}`,
+)
+// 加载 Vue 模式的插件仪表盘远程组件,并缓存当前节点的加载 Promise。
+function loadPluginDashboardComponent(pluginId: string) {
if (!pluginDashboardComponentLoadPromise) {
- pluginDashboardComponentLoadPromise = loadRemoteComponent(props.config.id, 'Dashboard').catch(error => {
- pluginDashboardComponentLoadPromise = null
+ const loadPromise = loadRemoteComponent(pluginId, 'Dashboard')
+ const guardedPromise = loadPromise.catch(error => {
+ if (pluginDashboardComponentLoadPromise === guardedPromise) {
+ pluginDashboardComponentLoadPromise = null
+ }
throw error
})
+ pluginDashboardComponentLoadPromise = guardedPromise
}
return pluginDashboardComponentLoadPromise
}
-// Vue 模式:动态加载的组件
-const dynamicPluginComponent = defineAsyncComponent({
- // 工厂函数
- loader: async () => {
- try {
- const module = await loadPluginDashboardComponent()
+// 每个插件节点身份使用独立异步组件,避免 Vue 复用前一个 remote 的成功解析结果。
+const dynamicPluginComponent = computed(() => {
+ const pluginId = props.config?.id
+ const identity = pluginDashboardIdentity.value
- // 直接返回加载的组件,无需再获取default
- return module
- } catch (error) {
- console.error('加载远程组件失败:', error)
- throw error
- }
- },
- // 加载中显示的组件
- loadingComponent: DashboardSkeleton,
- // 添加错误处理
- errorComponent: {
- template: `
-
-
- 无法加载组件,请稍后再试
-
-
- `,
- },
+ return defineAsyncComponent({
+ loader: async () => {
+ try {
+ if (!pluginId) throw new Error(`插件ID不存在: ${identity}`)
+ return await loadPluginDashboardComponent(pluginId)
+ } catch (error) {
+ console.error('加载远程组件失败:', error)
+ throw error
+ }
+ },
+ loadingComponent: DashboardSkeleton,
+ errorComponent: RemoteComponentError,
+ })
})
// 判断当前配置是否对应内置异步仪表盘组件。
@@ -179,28 +180,38 @@ function emitDashboardElementLoaded() {
}
// 等待当前仪表盘节点的异步组件加载完成,静态渲染模式则等待一次 DOM 更新。
-async function waitForDashboardElementLoaded() {
+async function waitForDashboardElementLoaded(generation: number) {
if (isDashboardElementLoaded.value) return
try {
if (isBuiltInDashboardElement() && props.config?.id) {
await loadBuiltInDashboardComponent(props.config.id)
- } else if (isVuePluginDashboardElement()) {
- await loadPluginDashboardComponent()
+ } else if (isVuePluginDashboardElement() && props.config?.id) {
+ await loadPluginDashboardComponent(props.config.id)
}
await nextTick()
} catch (error) {
console.error(error)
} finally {
- emitDashboardElementLoaded()
+ if (generation === dashboardLoadGeneration) emitDashboardElementLoaded()
}
}
watch(
() => [props.config?.id, props.config?.key, pluginRenderMode.value],
- () => {
- void waitForDashboardElementLoaded()
+ (_identity, previousIdentity) => {
+ const pluginIdentityChanged =
+ previousIdentity &&
+ (pluginRenderMode.value === 'vue' || previousIdentity[2] === 'vue') &&
+ previousIdentity.some((value, index) => value !== _identity[index])
+ if (pluginIdentityChanged) {
+ isDashboardElementLoaded.value = false
+ pluginDashboardComponentLoadPromise = null
+ }
+
+ const generation = ++dashboardLoadGeneration
+ void waitForDashboardElementLoaded(generation)
},
{ immediate: true },
)
@@ -233,6 +244,7 @@ onUnmounted(() => {
+
+
+ 无法加载组件,请稍后再试
+
+
diff --git a/src/components/misc/RemoteComponentLoading.vue b/src/components/misc/RemoteComponentLoading.vue
new file mode 100644
index 00000000..896221e5
--- /dev/null
+++ b/src/components/misc/RemoteComponentLoading.vue
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/src/components/misc/__tests__/DashboardElement.spec.ts b/src/components/misc/__tests__/DashboardElement.spec.ts
new file mode 100644
index 00000000..da3a9229
--- /dev/null
+++ b/src/components/misc/__tests__/DashboardElement.spec.ts
@@ -0,0 +1,254 @@
+import type { DashboardItem } from '@/api/types'
+import DashboardElement from '@/components/misc/DashboardElement.vue'
+import { renderWithProviders } from '@tests/support/render'
+import { screen, waitFor } from '@testing-library/vue'
+import { flushPromises } from '@vue/test-utils'
+import { defineComponent, h, inject, type Component, type PropType } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const mocks = vi.hoisted(() => ({
+ apiGet: vi.fn(),
+ loadRemoteComponent: vi.fn(),
+ nativeSubscribe: vi.fn(),
+ toast: { error: vi.fn(), success: vi.fn() },
+}))
+
+vi.mock('@/api', () => ({
+ default: { get: mocks.apiGet },
+}))
+
+vi.mock('@/utils/federationLoader', () => ({
+ loadRemoteComponent: mocks.loadRemoteComponent,
+}))
+
+vi.mock('@/composables/usePluginNativeSubscribe', () => ({
+ usePluginNativeSubscribe: () => mocks.nativeSubscribe,
+}))
+
+vi.mock('vue-toastification', () => ({
+ useToast: () => mocks.toast,
+}))
+
+type RemoteCapture = {
+ allowRefresh: boolean
+ api: unknown
+ config: DashboardItem
+ injectedNativeSubscribe: unknown
+ injectedToast: unknown
+ nativeSubscribe: unknown
+}
+
+function createDeferred() {
+ let reject!: (reason?: unknown) => void
+ let resolve!: (value: T | PromiseLike) => void
+ const promise = new Promise((promiseResolve, promiseReject) => {
+ resolve = promiseResolve
+ reject = promiseReject
+ })
+
+ return { promise, reject, resolve }
+}
+
+function createRemoteDashboard(captures: RemoteCapture[], label = 'remote'): Component {
+ return defineComponent({
+ name: 'RemoteDashboardFixture',
+ props: {
+ allowRefresh: Boolean,
+ api: Object,
+ config: { type: Object as PropType, required: true },
+ nativeSubscribe: Function,
+ },
+ setup(props) {
+ captures.push({
+ allowRefresh: props.allowRefresh,
+ api: props.api,
+ config: props.config,
+ injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'),
+ injectedToast: inject('moviepilot:toast'),
+ nativeSubscribe: props.nativeSubscribe,
+ })
+ return () => h('div', { 'data-testid': 'remote-dashboard' }, `${label}:${props.config.name}`)
+ },
+ })
+}
+
+function createPluginDashboard(overrides: Partial = {}): DashboardItem {
+ return {
+ attrs: { border: true, title: '插件仪表盘' },
+ cols: { lg: 6 },
+ elements: [],
+ id: 'DemoPlugin',
+ key: 'main',
+ name: '演示仪表盘',
+ render_mode: 'vue',
+ ...overrides,
+ }
+}
+
+describe('DashboardElement plugin host', () => {
+ beforeEach(() => {
+ mocks.loadRemoteComponent.mockReset()
+ mocks.apiGet.mockReset()
+ mocks.nativeSubscribe.mockReset()
+ mocks.toast.error.mockReset()
+ mocks.toast.success.mockReset()
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+
+ it('reuses one remote promise and passes identical prop/provide capabilities', async () => {
+ const captures: RemoteCapture[] = []
+ const config = createPluginDashboard()
+ mocks.loadRemoteComponent.mockResolvedValue(createRemoteDashboard(captures))
+
+ const result = await renderWithProviders(DashboardElement, {
+ props: { allowRefresh: false, config, refreshStatus: true },
+ })
+
+ expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote:演示仪表盘')
+ expect(mocks.loadRemoteComponent).toHaveBeenCalledOnce()
+ expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard')
+ expect(captures).toHaveLength(1)
+ expect(captures[0].config).toStrictEqual(config)
+ expect(captures[0].allowRefresh).toBe(false)
+ expect(captures[0].api).toMatchObject({ get: mocks.apiGet })
+ expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe)
+ expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe)
+ expect(captures[0].injectedToast).toBe(mocks.toast)
+ await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
+
+ result.unmount()
+ expect(result.emitted()['update:refreshStatus']).toEqual([[false]])
+ })
+
+ it('shows the remote error and retries after the plugin dashboard identity changes', async () => {
+ const captures: RemoteCapture[] = []
+ mocks.loadRemoteComponent
+ .mockRejectedValueOnce(new Error('remote unavailable'))
+ .mockResolvedValueOnce(createRemoteDashboard(captures))
+
+ const result = await renderWithProviders(DashboardElement, {
+ props: { config: createPluginDashboard(), refreshStatus: false },
+ })
+
+ expect(await screen.findByText('无法加载组件,请稍后再试')).toBeInTheDocument()
+ expect(mocks.loadRemoteComponent).toHaveBeenCalledOnce()
+
+ await result.rerender({
+ config: createPluginDashboard({ key: 'secondary', name: '恢复后的仪表盘' }),
+ refreshStatus: false,
+ })
+
+ expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote:恢复后的仪表盘')
+ expect(mocks.loadRemoteComponent).toHaveBeenCalledTimes(2)
+ })
+
+ it('replaces a successfully resolved remote component when the plugin identity changes', async () => {
+ const captures: RemoteCapture[] = []
+ mocks.loadRemoteComponent
+ .mockResolvedValueOnce(createRemoteDashboard(captures, 'remote-a'))
+ .mockResolvedValueOnce(createRemoteDashboard(captures, 'remote-b'))
+
+ const result = await renderWithProviders(DashboardElement, {
+ props: { config: createPluginDashboard(), refreshStatus: false },
+ })
+ expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-a:演示仪表盘')
+
+ await result.rerender({
+ config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '另一个仪表盘' }),
+ refreshStatus: false,
+ })
+
+ expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:另一个仪表盘')
+ expect(mocks.loadRemoteComponent).toHaveBeenNthCalledWith(2, 'OtherPlugin', 'Dashboard')
+ })
+
+ it('ignores an older remote that resolves after the replacement has loaded', async () => {
+ const captures: RemoteCapture[] = []
+ const remoteA = createDeferred()
+ const remoteB = createDeferred()
+ mocks.loadRemoteComponent.mockReturnValueOnce(remoteA.promise).mockReturnValueOnce(remoteB.promise)
+
+ const result = await renderWithProviders(DashboardElement, {
+ props: { config: createPluginDashboard(), refreshStatus: false },
+ })
+ await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard'))
+
+ await result.rerender({
+ config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '替换后的仪表盘' }),
+ refreshStatus: false,
+ })
+ await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('OtherPlugin', 'Dashboard'))
+
+ remoteB.resolve(createRemoteDashboard(captures, 'remote-b'))
+ expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:替换后的仪表盘')
+ await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
+
+ remoteA.resolve(createRemoteDashboard(captures, 'remote-a'))
+ await flushPromises()
+
+ expect(screen.getByTestId('remote-dashboard')).toHaveTextContent('remote-b:替换后的仪表盘')
+ expect(captures).toHaveLength(1)
+ expect(captures[0].config.id).toBe('OtherPlugin')
+ expect(result.emitted().loaded).toHaveLength(1)
+ })
+
+ it('reports loaded only after the current remote resolves', async () => {
+ const captures: RemoteCapture[] = []
+ const remoteA = createDeferred()
+ const remoteB = createDeferred()
+ mocks.loadRemoteComponent.mockReturnValueOnce(remoteA.promise).mockReturnValueOnce(remoteB.promise)
+
+ const result = await renderWithProviders(DashboardElement, {
+ props: { config: createPluginDashboard(), refreshStatus: false },
+ })
+ await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('DemoPlugin', 'Dashboard'))
+
+ await result.rerender({
+ config: createPluginDashboard({ id: 'OtherPlugin', key: 'other', name: '当前仪表盘' }),
+ refreshStatus: false,
+ })
+ await waitFor(() => expect(mocks.loadRemoteComponent).toHaveBeenCalledWith('OtherPlugin', 'Dashboard'))
+
+ remoteA.resolve(createRemoteDashboard(captures, 'remote-a'))
+ await flushPromises()
+
+ expect(result.emitted().loaded).toBeUndefined()
+ expect(captures).toHaveLength(0)
+
+ remoteB.resolve(createRemoteDashboard(captures, 'remote-b'))
+ expect(await screen.findByTestId('remote-dashboard')).toHaveTextContent('remote-b:当前仪表盘')
+ await waitFor(() => expect(result.emitted().loaded).toHaveLength(1))
+ })
+
+ it('renders the Vuetify plugin branch without loading a remote component', async () => {
+ const DashboardRenderStub = defineComponent({
+ name: 'DashboardRender',
+ props: { config: { type: Object as PropType>, required: true } },
+ setup: props => () => h('div', { 'data-testid': 'dashboard-render' }, String(props.config.component)),
+ })
+
+ await renderWithProviders(DashboardElement, {
+ props: {
+ config: createPluginDashboard({
+ elements: [{ component: 'VChip' }],
+ render_mode: 'vuetify',
+ }),
+ },
+ global: { stubs: { DashboardRender: DashboardRenderStub } },
+ })
+
+ expect(await screen.findByTestId('dashboard-render')).toHaveTextContent('VChip')
+ expect(screen.getByText('插件仪表盘')).toBeInTheDocument()
+ expect(mocks.loadRemoteComponent).not.toHaveBeenCalled()
+ })
+
+ it('shows an explicit error for an unsupported plugin render mode', async () => {
+ await renderWithProviders(DashboardElement, {
+ props: { config: createPluginDashboard({ render_mode: 'legacy' }) },
+ })
+
+ expect(await screen.findByText('无法渲染插件仪表盘部件: 未知渲染模式或配置错误')).toBeInTheDocument()
+ expect(mocks.loadRemoteComponent).not.toHaveBeenCalled()
+ })
+})
diff --git a/vite.config.ts b/vite.config.ts
index cd00631f..8dc89550 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -354,6 +354,8 @@ export default defineConfig(({ command, mode, isPreview }) => ({
'src/components/dialog/AddSubtitleDownloadDialog.vue',
'src/components/dialog/ReorganizeDialog.vue',
'src/components/dialog/TransferQueueDialog.vue',
+ 'src/components/dialog/PluginConfigDialog.vue',
+ 'src/components/dialog/PluginDataDialog.vue',
'src/views/reorganize/FileBrowserView.vue',
'src/components/filebrowser/FileBrowser.vue',
'src/components/filebrowser/FileToolbar.vue',
@@ -516,6 +518,18 @@ export default defineConfig(({ command, mode, isPreview }) => ({
lines: 85,
statements: 85,
},
+ 'src/components/dialog/PluginConfigDialog.vue': {
+ branches: 80,
+ functions: 85,
+ lines: 85,
+ statements: 85,
+ },
+ 'src/components/dialog/PluginDataDialog.vue': {
+ branches: 80,
+ functions: 85,
+ lines: 85,
+ statements: 85,
+ },
'src/composables/useMediaSubscribe.ts': {
branches: 75,
functions: 80,