diff --git a/docs/module-federation-guide.md b/docs/module-federation-guide.md index 0dc0939e..dd364809 100644 --- a/docs/module-federation-guide.md +++ b/docs/module-federation-guide.md @@ -319,8 +319,10 @@ const emit = defineEmits(['action']) | 认证 API | ✓ | ✓ | ✓ | ✓ | `api` prop | | 原生订阅交互 | ✓ | ✓ | ✓ | ✓ | `nativeSubscribe` prop 或 `inject('moviepilot:nativeSubscribe')` | | 主应用统一 Toast | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:toast')` | +| 主应用公共弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:dialog')` | +| 主应用确认弹窗 | ✓ | ✓ | ✓ | ✓ | `inject('moviepilot:confirm')` | -`nativeSubscribe` 和 Toast 都由主应用宿主提供。插件不应复制主程序订阅弹窗,也不应自行创建另一套 Toast 容器。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。 +`nativeSubscribe`、Toast、公共弹窗和确认弹窗都由主应用宿主提供。插件不应复制主程序订阅弹窗、创建另一套 Toast 容器或自行挂载全局弹窗。插件在旧版主程序或能力不存在的环境中运行时,应保留空值判断和必要的页面内 fallback。 ### 5.6 玻璃光学表面 @@ -397,6 +399,90 @@ function saveComplete() { 可用方法与主项目 `vue-toastification` 一致,包括 `success`、`info`、`warning` 和 `error`。注入不存在时应静默降级,关键错误仍需保留页面内状态提示。 +### 5.9 调用主应用公共弹窗 + +`Page`、`Config`、`Dashboard` 与 `AppPage` 的宿主容器会通过固定键提供主应用公共弹窗函数。该函数会将插件组件挂载到主应用 `App.vue` 的 `SharedDialogHost`,因此弹窗不会受插件页面、卡片或父级容器的层叠上下文限制。远程组件应复用该入口,不要自行创建额外的弹窗容器: + +```vue + +``` + +公共弹窗函数签名为 `openDialog(component, props, events, options)`,返回控制器: + +- `closeOn`:收到指定事件后自动从公共层移除,默认监听 `close`;传 `false` 表示不自动关闭; +- `replace`:是否替换当前公共弹窗栈; +- `props` 和 `events`:也可以使用 `openDialogWithOptions` 的对象参数形式传入; +- `close()`:主动关闭当前弹窗; +- `updateProps(props)`:合并更新已打开弹窗的 props。 + +插件弹窗组件应提供 `close` 或 `update:modelValue` 事件,并自行处理组件内部交互。旧版主应用未提供该注入时,插件应保留页面内弹窗或其他 fallback。 + +### 5.10 调用主应用确认弹窗 + +确认弹窗使用独立的固定键,适合不需要自定义组件内容的确认场景: + +```vue + +``` + +确认弹窗返回 `Promise`:用户点击确认时为 `true`,点击取消或关闭按钮时为 `false`;注入能力不存在时返回 `undefined`,插件应按未确认处理。可用配置项包括 `type`(`info` / `warn` / `error`)、`title`、`content`、`confirmText`、`cancelText` 和 `width`。 + #### 后端:注册侧栏入口 插件需为 **Vue** 渲染模式(`get_render_mode` 返回 `vue`),并实现 `get_sidebar_nav`,返回列表项字段与主应用 `GET /api/v1/plugin/sidebar_nav` 一致: diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index a1cffad6..c97eae10 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -183,7 +183,7 @@ describe('MoviePilot API client', () => { it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => { const reportConnectionFailure = vi.fn() const adapter: AxiosAdapter = async config => { - throw new CanceledError('Request cancelled', AxiosError.ERR_CANCELED, config) + throw new CanceledError('Request cancelled', config) } const { api } = createApiClients({ adapter, hooks: { reportConnectionFailure }, notifier }) diff --git a/src/api/types.ts b/src/api/types.ts index c542703e..a34c4b8e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,4 @@ -/** 后端、前端、插件与中心服务共同使用的固定媒体来源枚举。 */ +/** 主程序内置的媒体来源常量;插件可以注册额外的稳定来源标识。 */ export enum MediaSource { TMDB = 'themoviedb', Douban = 'douban', @@ -15,7 +15,7 @@ export enum MediaSource { TencentVideo = 'tencentvideodiscover', } -export type MediaDataSource = `${MediaSource}` +export type MediaDataSource = `${MediaSource}` | (string & {}) // 手动刮削选项 export interface ManualScrapeOptions { @@ -2015,6 +2015,8 @@ export interface TransferQueue { export interface DiscoverSource { // 数据源名称 name: string + // 内置或插件扩展媒体来源 + media_source?: MediaDataSource // 媒体ID的前缀,不含: mediaid_prefix: string // 媒体数据源API地址 diff --git a/src/components/dialog/PluginConfigDialog.vue b/src/components/dialog/PluginConfigDialog.vue index a897739a..260193d0 100644 --- a/src/components/dialog/PluginConfigDialog.vue +++ b/src/components/dialog/PluginConfigDialog.vue @@ -9,6 +9,8 @@ import ProgressDialog from '../dialog/ProgressDialog.vue' import { useI18n } from 'vue-i18n' import { loadRemoteComponent } from '@/utils/federationLoader' import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe' +import { useConfirm } from '@/composables/useConfirm' +import { openSharedDialog } from '@/composables/useSharedDialog' import { usePluginSidebarNavStore } from '@/stores/pluginSidebarNav' import RemoteComponentError from '@/components/misc/RemoteComponentError.vue' import RemoteComponentLoading from '@/components/misc/RemoteComponentLoading.vue' @@ -47,6 +49,13 @@ const $toast = useToast() // 向联邦插件提供主应用 Toast,避免远程组件自行创建通知容器。 provide('moviepilot:toast', $toast) +// 向联邦插件提供主应用公共弹窗,内容由 App 的 SharedDialogHost 统一承载。 +provide('moviepilot:dialog', openSharedDialog) + +// 确认弹窗单独提供,保持简单确认调用的兼容性。 +const createConfirm = useConfirm() +provide('moviepilot:confirm', createConfirm) + // 配置联邦组件沿用与其它插件宿主一致的原生订阅能力。 const nativeSubscribe = usePluginNativeSubscribe() provide('moviepilot:nativeSubscribe', nativeSubscribe) diff --git a/src/components/dialog/PluginDataDialog.vue b/src/components/dialog/PluginDataDialog.vue index 1d4b4f34..f2a8242d 100644 --- a/src/components/dialog/PluginDataDialog.vue +++ b/src/components/dialog/PluginDataDialog.vue @@ -7,6 +7,8 @@ import { loadRemoteComponent } from '@/utils/federationLoader' import { usePWA } from '@/composables/usePWA' import { useToast } from 'vue-toastification' import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe' +import { useConfirm } from '@/composables/useConfirm' +import { openSharedDialog } from '@/composables/useSharedDialog' import RemoteComponentError from '@/components/misc/RemoteComponentError.vue' import RemoteComponentLoading from '@/components/misc/RemoteComponentLoading.vue' @@ -34,6 +36,13 @@ const { appMode } = usePWA() const $toast = useToast() provide('moviepilot:toast', $toast) +// 向联邦插件提供主应用公共弹窗,内容由 App 的 SharedDialogHost 统一承载。 +provide('moviepilot:dialog', openSharedDialog) + +// 确认弹窗单独提供,避免把简单确认与自定义组件弹窗混为一谈。 +const createConfirm = useConfirm() +provide('moviepilot:confirm', createConfirm) + // 向联邦插件同时提供 prop 与 inject 形式的主程序原生订阅入口。 const nativeSubscribe = usePluginNativeSubscribe() provide('moviepilot:nativeSubscribe', nativeSubscribe) diff --git a/src/components/dialog/__tests__/PluginConfigDialog.spec.ts b/src/components/dialog/__tests__/PluginConfigDialog.spec.ts index 2bb4ceb6..fd62b2a4 100644 --- a/src/components/dialog/__tests__/PluginConfigDialog.spec.ts +++ b/src/components/dialog/__tests__/PluginConfigDialog.spec.ts @@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => { apiPut, ensureSidebarNav: vi.fn(), loadRemoteComponent: vi.fn(), + openSharedDialog: vi.fn(), nativeSubscribe: vi.fn(), + createConfirm: vi.fn(), toast: { error: vi.fn(), success: vi.fn() }, } }) @@ -33,6 +35,14 @@ vi.mock('@/composables/usePluginNativeSubscribe', () => ({ usePluginNativeSubscribe: () => mocks.nativeSubscribe, })) +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.createConfirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: mocks.openSharedDialog, +})) + vi.mock('@/stores/pluginSidebarNav', () => ({ usePluginSidebarNavStore: () => ({ ensureSidebarNav: mocks.ensureSidebarNav }), })) @@ -89,6 +99,8 @@ type RemoteCapture = { initialConfig: Record injectedNativeSubscribe: unknown injectedToast: unknown + injectedDialog: unknown + injectedConfirm: unknown nativeSubscribe: unknown } @@ -118,6 +130,8 @@ function createRemoteConfig(captures: RemoteCapture[]): Component { initialConfig: props.initialConfig, injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'), injectedToast: inject('moviepilot:toast'), + injectedDialog: inject('moviepilot:dialog'), + injectedConfirm: inject('moviepilot:confirm'), nativeSubscribe: props.nativeSubscribe, }) return () => @@ -152,7 +166,9 @@ describe('PluginConfigDialog', () => { mocks.apiPut.mockReset() mocks.ensureSidebarNav.mockReset().mockResolvedValue(undefined) mocks.loadRemoteComponent.mockReset() + mocks.openSharedDialog.mockReset() mocks.nativeSubscribe.mockReset() + mocks.createConfirm.mockReset() mocks.toast.error.mockReset() mocks.toast.success.mockReset() vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -229,6 +245,8 @@ describe('PluginConfigDialog', () => { expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe) expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe) expect(captures[0].injectedToast).toBe(mocks.toast) + expect(captures[0].injectedDialog).toBe(mocks.openSharedDialog) + expect(captures[0].injectedConfirm).toBe(mocks.createConfirm) await fireEvent.click(screen.getByRole('button', { name: '调整布局' })) expect(screen.getByRole('dialog')).toHaveAttribute('data-max-width', '72rem') diff --git a/src/components/dialog/__tests__/PluginDataDialog.spec.ts b/src/components/dialog/__tests__/PluginDataDialog.spec.ts index 0be67640..d5ef65df 100644 --- a/src/components/dialog/__tests__/PluginDataDialog.spec.ts +++ b/src/components/dialog/__tests__/PluginDataDialog.spec.ts @@ -12,7 +12,9 @@ const mocks = vi.hoisted(() => { api: { get: apiGet }, apiGet, loadRemoteComponent: vi.fn(), + openSharedDialog: vi.fn(), nativeSubscribe: vi.fn(), + createConfirm: vi.fn(), toast: { error: vi.fn(), success: vi.fn() }, } }) @@ -30,6 +32,14 @@ vi.mock('@/composables/usePluginNativeSubscribe', () => ({ usePluginNativeSubscribe: () => mocks.nativeSubscribe, })) +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.createConfirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: mocks.openSharedDialog, +})) + vi.mock('@/composables/usePWA', () => ({ usePWA: () => ({ appMode: false }), })) @@ -88,6 +98,8 @@ type RemoteCapture = { api: unknown injectedNativeSubscribe: unknown injectedToast: unknown + injectedDialog: unknown + injectedConfirm: unknown nativeSubscribe: unknown showSwitch: boolean } @@ -115,6 +127,8 @@ function createRemotePage(captures: RemoteCapture[]): Component { api: props.api, injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'), injectedToast: inject('moviepilot:toast'), + injectedDialog: inject('moviepilot:dialog'), + injectedConfirm: inject('moviepilot:confirm'), nativeSubscribe: props.nativeSubscribe, showSwitch: props.show_switch, }) @@ -147,7 +161,9 @@ describe('PluginDataDialog', () => { beforeEach(() => { mocks.apiGet.mockReset() mocks.loadRemoteComponent.mockReset() + mocks.openSharedDialog.mockReset() mocks.nativeSubscribe.mockReset() + mocks.createConfirm.mockReset() mocks.toast.error.mockReset() mocks.toast.success.mockReset() vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -202,6 +218,8 @@ describe('PluginDataDialog', () => { expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe) expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe) expect(captures[0].injectedToast).toBe(mocks.toast) + expect(captures[0].injectedDialog).toBe(mocks.openSharedDialog) + expect(captures[0].injectedConfirm).toBe(mocks.createConfirm) await fireEvent.click(screen.getByRole('button', { name: '刷新远程页面' })) await fireEvent.click(screen.getByRole('button', { name: '切换配置' })) diff --git a/src/components/misc/DashboardElement.vue b/src/components/misc/DashboardElement.vue index 55da3eb2..b83896fb 100644 --- a/src/components/misc/DashboardElement.vue +++ b/src/components/misc/DashboardElement.vue @@ -7,6 +7,8 @@ import { isNullOrEmptyObject } from '@/@core/utils' import { loadRemoteComponent } from '@/utils/federationLoader' import { useToast } from 'vue-toastification' import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe' +import { useConfirm } from '@/composables/useConfirm' +import { openSharedDialog } from '@/composables/useSharedDialog' import RemoteComponentError from './RemoteComponentError.vue' type DashboardComponentLoader = () => Promise @@ -15,6 +17,13 @@ type DashboardComponentLoader = () => Promise const $toast = useToast() provide('moviepilot:toast', $toast) +// 向仪表板联邦组件导出主应用公共弹窗入口。 +provide('moviepilot:dialog', openSharedDialog) + +// 确认弹窗单独提供,避免插件自行挂载确认组件。 +const createConfirm = useConfirm() +provide('moviepilot:confirm', createConfirm) + // 向仪表板联邦组件导出主程序原生订阅入口。 const nativeSubscribe = usePluginNativeSubscribe() provide('moviepilot:nativeSubscribe', nativeSubscribe) diff --git a/src/components/misc/__tests__/DashboardElement.spec.ts b/src/components/misc/__tests__/DashboardElement.spec.ts index 88332915..5dc28975 100644 --- a/src/components/misc/__tests__/DashboardElement.spec.ts +++ b/src/components/misc/__tests__/DashboardElement.spec.ts @@ -9,7 +9,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ apiGet: vi.fn(), loadRemoteComponent: vi.fn(), + openSharedDialog: vi.fn(), nativeSubscribe: vi.fn(), + createConfirm: vi.fn(), toast: { error: vi.fn(), success: vi.fn() }, })) @@ -26,6 +28,14 @@ vi.mock('@/composables/usePluginNativeSubscribe', () => ({ usePluginNativeSubscribe: () => mocks.nativeSubscribe, })) +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.createConfirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: mocks.openSharedDialog, +})) + vi.mock('vue-toastification', () => ({ useToast: () => mocks.toast, })) @@ -35,6 +45,8 @@ type RemoteCapture = { api: unknown config: DashboardItem injectedNativeSubscribe: unknown + injectedDialog: unknown + injectedConfirm: unknown injectedToast: unknown nativeSubscribe: unknown } @@ -66,6 +78,8 @@ function createRemoteDashboard(captures: RemoteCapture[], label = 'remote'): Com config: props.config, injectedNativeSubscribe: inject('moviepilot:nativeSubscribe'), injectedToast: inject('moviepilot:toast'), + injectedDialog: inject('moviepilot:dialog'), + injectedConfirm: inject('moviepilot:confirm'), nativeSubscribe: props.nativeSubscribe, }) return () => h('div', { 'data-testid': 'remote-dashboard' }, `${label}:${props.config.name}`) @@ -89,8 +103,10 @@ function createPluginDashboard(overrides: Partial = {}): Dashboar describe('DashboardElement plugin host', () => { beforeEach(() => { mocks.loadRemoteComponent.mockReset() + mocks.openSharedDialog.mockReset() mocks.apiGet.mockReset() mocks.nativeSubscribe.mockReset() + mocks.createConfirm.mockReset() mocks.toast.error.mockReset() mocks.toast.success.mockReset() vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -116,6 +132,8 @@ describe('DashboardElement plugin host', () => { expect(captures[0].nativeSubscribe).toBe(mocks.nativeSubscribe) expect(captures[0].injectedNativeSubscribe).toBe(mocks.nativeSubscribe) expect(captures[0].injectedToast).toBe(mocks.toast) + expect(captures[0].injectedDialog).toBe(mocks.openSharedDialog) + expect(captures[0].injectedConfirm).toBe(mocks.createConfirm) await waitFor(() => expect(result.emitted().loaded).toHaveLength(1)) result.unmount() diff --git a/src/composables/__tests__/usePluginNativeSubscribe.spec.ts b/src/composables/__tests__/usePluginNativeSubscribe.spec.ts index f2986303..71e0edf3 100644 --- a/src/composables/__tests__/usePluginNativeSubscribe.spec.ts +++ b/src/composables/__tests__/usePluginNativeSubscribe.spec.ts @@ -119,13 +119,32 @@ describe('native subscribe media normalization', () => { }) }) + it('accepts a plugin-defined media source identity', () => { + const result = normalizeNativeSubscribeMedia({ + media_id: 'custom-42', + media_source: 'acme.video', + title: '插件媒体', + type: 'movie', + }) + + expect(result).toEqual({ + success: true, + media: expect.objectContaining({ + media_id: 'custom-42', + media_source: 'acme.video', + title: '插件媒体', + type: '电影', + }), + }) + }) + it.each([ [null, 'invalidMedia'], [{ title: '缺少类型', tmdb_id: 1 }, 'unsupportedType'], [{ title: '', tmdb_id: 1, type: '电影' }, 'missingTitle'], [{ title: '缺少ID', type: '电视剧' }, 'missingId'], [{ title: '仅有旧来源 ID', tmdb_id: 1, type: '电影' }, 'missingId'], - [{ media_id: '1', media_source: 'custom-source', title: '未知来源', type: '电影' }, 'missingId'], + [{ media_id: '1', media_source: 'invalid:source', title: '非法来源', type: '电影' }, 'missingId'], ])('rejects invalid input %#', (input, reason) => { expect(normalizeNativeSubscribeMedia(input)).toEqual({ success: false, reason }) }) diff --git a/src/composables/useConfirm.ts b/src/composables/useConfirm.ts index fe3277a0..8b0e2a43 100644 --- a/src/composables/useConfirm.ts +++ b/src/composables/useConfirm.ts @@ -5,7 +5,8 @@ import vuetify from '@/plugins/vuetify' import ConfirmDialog from '@/@core/components/ConfirmDialog.vue' import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue' -interface ConfirmOptions { +/** 主应用确认弹窗支持的配置项。 */ +export interface ConfirmOptions { type?: 'info' | 'warn' | 'error' title?: string content?: string @@ -14,9 +15,12 @@ interface ConfirmOptions { width?: string | number } +/** 可注入到联邦插件中的确认弹窗调用入口。 */ +export type ConfirmDialogFn = (options?: ConfirmOptions) => Promise + let resolvePromise: ((value: boolean) => void) | null = null -// 创建确认对话框实例 +/** 创建主应用确认弹窗并等待用户选择结果。 */ async function createConfirmDialog(options: ConfirmOptions = {}) { return new Promise(resolve => { resolvePromise = resolve @@ -73,9 +77,9 @@ async function createConfirmDialog(options: ConfirmOptions = {}) { // 创建一个函数对象,同时支持直接调用和解构 const confirmFunction = Object.assign(createConfirmDialog, { createConfirm: createConfirmDialog, -}) +}) as ConfirmDialogFn & { createConfirm: ConfirmDialogFn } -// 导出 useConfirm 函数 +/** 返回可复用的主应用确认弹窗调用入口。 */ export function useConfirm() { return confirmFunction } diff --git a/src/composables/useSharedDialog.ts b/src/composables/useSharedDialog.ts index 85358fba..b0698439 100644 --- a/src/composables/useSharedDialog.ts +++ b/src/composables/useSharedDialog.ts @@ -18,6 +18,13 @@ export interface SharedDialogEntry { visible: boolean } +/** 公共弹窗打开后返回的命令式控制器。 */ +export interface SharedDialogController { + id: number + close: () => void + updateProps: (props: Record) => void +} + const DEFAULT_CLOSE_EVENTS = ['close'] const dialogStack = shallowRef([]) let dialogSeed = 0 @@ -39,7 +46,7 @@ export function openSharedDialog( props: Record = {}, events: Record = {}, options: Omit = {}, -) { +): SharedDialogController { const id = ++dialogSeed const entry: SharedDialogEntry = { closeOn: normalizeCloseEvents(options.closeOn), @@ -59,6 +66,9 @@ export function openSharedDialog( } } +/** 可注入到联邦插件中的公共弹窗打开函数类型。 */ +export type SharedDialogOpenFn = typeof openSharedDialog + // 使用对象参数打开共享弹窗,适合调用方需要传入更多选项的场景。 export function openSharedDialogWithOptions(component: Component, options: SharedDialogOpenOptions = {}) { return openSharedDialog(component, options.props ?? {}, options.events ?? {}, { diff --git a/src/pages/__tests__/browse.spec.ts b/src/pages/__tests__/browse.spec.ts index 5f982615..81e9abb1 100644 --- a/src/pages/__tests__/browse.spec.ts +++ b/src/pages/__tests__/browse.spec.ts @@ -96,7 +96,7 @@ describe('browse page', () => { expect(screen.queryByRole('region', { name: '媒体 browse 列表' })).not.toBeInTheDocument() }) - it('normalizes only unified media search sources into a deduplicated enum array', async () => { + it('normalizes unified media search sources into a deduplicated extensible array', async () => { await renderBrowse(['media', 'search'], { media_source: 'themoviedb,unknown,douban,themoviedb', page: '4', @@ -105,7 +105,7 @@ describe('browse page', () => { }) expect(projectedQuery('媒体 browse 查询')).toEqual({ - media_source: ['themoviedb', 'douban'], + media_source: ['themoviedb', 'unknown', 'douban'], page: '4', title: '多来源搜索', type: 'movie', @@ -114,7 +114,7 @@ describe('browse page', () => { it('drops an invalid media source only from unified media search', async () => { await renderBrowse(['media', 'search'], { - media_source: 'unknown', + media_source: 'invalid:source', title: '无有效来源', type: 'movie', }) diff --git a/src/pages/__tests__/media.spec.ts b/src/pages/__tests__/media.spec.ts index ba631ef9..83a710d0 100644 --- a/src/pages/__tests__/media.spec.ts +++ b/src/pages/__tests__/media.spec.ts @@ -58,9 +58,9 @@ describe('media page', () => { expect(projectedProps()).toEqual({}) }) - it('drops an unknown media source at the route boundary', async () => { + it('keeps a plugin media source at the route boundary', async () => { await renderPage({ media_id: '101', media_source: 'custom-source', type: '电影' }) - expect(projectedProps()).toEqual({ mediaId: '101', type: '电影' }) + expect(projectedProps()).toEqual({ mediaId: '101', mediaSource: 'custom-source', type: '电影' }) }) }) diff --git a/src/pages/__tests__/music.spec.ts b/src/pages/__tests__/music.spec.ts index e9047a9f..eb9215eb 100644 --- a/src/pages/__tests__/music.spec.ts +++ b/src/pages/__tests__/music.spec.ts @@ -167,7 +167,7 @@ describe('music page', () => { type: 'music', count: 30, title: 'Coldplay', - media_source: ['musicbrainz', 'theaudiodb'], + media_source: ['musicbrainz', 'unknown', 'theaudiodb'], }, paramsSerializer: { indexes: null }, }), diff --git a/src/pages/__tests__/plugin-app.spec.ts b/src/pages/__tests__/plugin-app.spec.ts index de986d89..2183ba5d 100644 --- a/src/pages/__tests__/plugin-app.spec.ts +++ b/src/pages/__tests__/plugin-app.spec.ts @@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ api: { get: vi.fn(), post: vi.fn() }, loadRemoteAppPageComponent: vi.fn(), + openSharedDialog: vi.fn(), nativeSubscribe: vi.fn(), + createConfirm: vi.fn(), route: undefined as unknown as { params: { navKey?: string; pluginId?: string } }, toast: { error: vi.fn(), success: vi.fn() }, })) @@ -25,6 +27,14 @@ vi.mock('@/composables/usePluginNativeSubscribe', () => ({ usePluginNativeSubscribe: () => mocks.nativeSubscribe, })) +vi.mock('@/composables/useConfirm', () => ({ + useConfirm: () => mocks.createConfirm, +})) + +vi.mock('@/composables/useSharedDialog', () => ({ + openSharedDialog: mocks.openSharedDialog, +})) + vi.mock('vue-toastification', () => ({ useToast: () => mocks.toast, })) @@ -70,6 +80,8 @@ function capabilityPage() { setup(props, { emit }) { const injectedToast = inject('moviepilot:toast') const injectedNativeSubscribe = inject('moviepilot:nativeSubscribe') + const injectedDialog = inject('moviepilot:dialog') + const injectedConfirm = inject('moviepilot:confirm') return () => h( 'button', @@ -81,6 +93,8 @@ function capabilityPage() { props.nativeSubscribe === mocks.nativeSubscribe, injectedToast === mocks.toast, injectedNativeSubscribe === mocks.nativeSubscribe, + injectedDialog === mocks.openSharedDialog, + injectedConfirm === mocks.createConfirm, ].join(':'), ) }, @@ -90,9 +104,11 @@ function capabilityPage() { describe('plugin-app page', () => { beforeEach(() => { mocks.loadRemoteAppPageComponent.mockReset() + mocks.openSharedDialog.mockReset() mocks.api.get.mockReset() mocks.api.post.mockReset() mocks.nativeSubscribe.mockReset() + mocks.createConfirm.mockReset() mocks.route = reactive({ params: { navKey: 'main', pluginId: 'alpha' } }) mocks.toast.error.mockReset() mocks.toast.success.mockReset() @@ -109,7 +125,7 @@ describe('plugin-app page', () => { pageLoad.resolve(capabilityPage()) const remoteAction = await screen.findByRole('button', { - name: 'alpha:settings:true:true:true:true', + name: 'alpha:settings:true:true:true:true:true:true', }) await fireEvent.click(remoteAction) diff --git a/src/pages/plugin-app.vue b/src/pages/plugin-app.vue index 3684ade4..6e1d8c96 100644 --- a/src/pages/plugin-app.vue +++ b/src/pages/plugin-app.vue @@ -4,6 +4,8 @@ import { pluginApi } from '@/api' import { loadRemoteAppPageComponent } from '@/utils/federationLoader' import { useToast } from 'vue-toastification' import { usePluginNativeSubscribe } from '@/composables/usePluginNativeSubscribe' +import { useConfirm } from '@/composables/useConfirm' +import { openSharedDialog } from '@/composables/useSharedDialog' const route = useRoute() @@ -18,6 +20,13 @@ let loadGeneration = 0 const $toast = useToast() provide('moviepilot:toast', $toast) +// 向侧栏全页联邦组件导出主应用公共弹窗入口。 +provide('moviepilot:dialog', openSharedDialog) + +// 确认弹窗单独提供,保留简单的 Promise 调用方式。 +const createConfirm = useConfirm() +provide('moviepilot:confirm', createConfirm) + // 向侧栏全页联邦组件导出主程序原生订阅入口。 const nativeSubscribe = usePluginNativeSubscribe() provide('moviepilot:nativeSubscribe', nativeSubscribe) diff --git a/src/pages/resource.vue b/src/pages/resource.vue index d4f371b3..361dd8b4 100644 --- a/src/pages/resource.vue +++ b/src/pages/resource.vue @@ -78,9 +78,9 @@ type TorrentViewType = 'card' | 'row' // 只有最新搜索可以提交结果和可重放参数,避免旧请求覆盖新查询。 let activeSearchRequestId = 0 -/** 只接受产品协议中固定的数据源枚举,避免未知来源进入搜索链路。 */ +/** 接受内置或插件扩展来源,并拒绝格式非法的来源标识。 */ function normalizeMediaSource(value: unknown): MediaDataSource | '' { - const normalized = value?.toString().trim() + const normalized = value?.toString().trim().toLowerCase() return isMediaDataSource(normalized) ? normalized : '' } diff --git a/src/utils/__tests__/mediaId.spec.ts b/src/utils/__tests__/mediaId.spec.ts index 042ad2a2..a616e4f1 100644 --- a/src/utils/__tests__/mediaId.spec.ts +++ b/src/utils/__tests__/mediaId.spec.ts @@ -8,7 +8,8 @@ describe('media source identity utils', () => { it.each([ ['themoviedb', ['themoviedb']], - [' musicbrainz, theaudiodb,unknown,musicbrainz ', ['musicbrainz', 'theaudiodb']], + [' musicbrainz, theaudiodb,unknown,musicbrainz ', ['musicbrainz', 'theaudiodb', 'unknown']], + [' Acme.Video,invalid:source,acme.video ', ['acme.video']], [ ['douban', 'anilist,bangumi', null, 'douban'], ['douban', 'anilist', 'bangumi'], diff --git a/src/utils/mediaId.ts b/src/utils/mediaId.ts index f991e32d..7d10302d 100644 --- a/src/utils/mediaId.ts +++ b/src/utils/mediaId.ts @@ -1,22 +1,23 @@ -import { MediaSource, type MediaDataSource } from '@/api/types' +import type { MediaDataSource } from '@/api/types' const MUSICBRAINZ_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const IMDB_ID_PATTERN = /^tt\d+$/i export const MUSIC_MEDIA_SOURCES = ['musicbrainz', 'theaudiodb', 'doubanmusic'] as const +const MEDIA_SOURCE_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/ -/** 判断外部输入是否属于产品协议中固定的媒体来源枚举。 */ +/** 判断外部输入是否为内置或插件注册的规范媒体来源标识。 */ export function isMediaDataSource(value: unknown): value is MediaDataSource { - return typeof value === 'string' && Object.values(MediaSource).includes(value as MediaSource) + return typeof value === 'string' && MEDIA_SOURCE_PATTERN.test(value) } -/** 将路由或表单中的单值、逗号分隔值及数组统一解析为去重后的媒体来源枚举。 */ +/** 将路由或表单中的单值、逗号分隔值及数组统一解析为去重后的规范来源。 */ export function parseMediaDataSources(value: unknown): MediaDataSource[] { const values = Array.isArray(value) ? value : [value] return [ ...new Set( values .flatMap(item => (typeof item === 'string' ? item.split(',') : [])) - .map(item => item.trim()) + .map(item => item.trim().toLowerCase()) .filter(isMediaDataSource), ), ]