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