fix(plugin): 完善市场安装状态与官方来源选择 (#723)

This commit is contained in:
InfinityPacer
2026-08-26 18:01:09 +08:00
committed by GitHub
parent f9ad7bc204
commit 4be29cc02f
12 changed files with 259 additions and 59 deletions
+12 -12
View File
@@ -3,7 +3,10 @@ import sonarjs from 'eslint-plugin-sonarjs'
import pluginVue from 'eslint-plugin-vue' import pluginVue from 'eslint-plugin-vue'
import globals from 'globals' import globals from 'globals'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores, includeIgnoreFile } from 'eslint/config'
import { fileURLToPath } from 'node:url'
const gitignorePath = fileURLToPath(new URL('.gitignore', import.meta.url))
const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}'] const javascriptFiles = ['**/*.{js,mjs,cjs,jsx}']
@@ -91,17 +94,14 @@ const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
})) }))
export default defineConfig([ export default defineConfig([
globalIgnores([ includeIgnoreFile(gitignorePath, {
'**/node_modules/**', gitignoreResolution: true,
'**/dist/**', name: 'moviepilot/gitignore',
'**/dev-dist/**', }),
'**/coverage/**', globalIgnores(
'**/.worktrees/**', ['**/.worktrees/**', '**/vite.config.*.timestamp-*.mjs', 'src/@iconify/**', '**/*.d.ts'],
'**/vite.config.*.timestamp-*.mjs', 'moviepilot/eslint-only-ignores',
'public/plugin_icon/**', ),
'src/@iconify/**',
'**/*.d.ts',
]),
{ {
...js.configs.recommended, ...js.configs.recommended,
name: 'moviepilot/javascript', name: 'moviepilot/javascript',
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import api from '@/api' import api from '@/api'
import { getApiBusinessErrorMessage } from '@/api/client' import { getApiBusinessErrorMessage } from '@/api/client'
import type { Plugin } from '@/api/types' import type { Plugin, PluginSourceOptions } from '@/api/types'
import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils' import { getLogoUrl, getProxyImageUrl } from '@/utils/imageUtils'
import { useGlobalSettingsStore } from '@/stores' import { useGlobalSettingsStore } from '@/stores'
import { usePluginCardAccent } from '@/composables/usePluginCardAccent' import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
@@ -18,7 +18,7 @@ const PluginVersionHistoryDialog = defineAsyncComponent(
) )
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue')) const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
type InstallHandler = (releaseVersion?: string, repoUrl?: string) => unknown type InstallHandler = (releaseVersion?: string, repoUrl?: string, sourceOptions?: PluginSourceOptions) => unknown
// 输入参数 // 输入参数
const props = defineProps({ const props = defineProps({
+8 -3
View File
@@ -37,6 +37,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
installing: {
type: Boolean,
default: false,
},
}) })
const globalSettingsStore = useGlobalSettingsStore() const globalSettingsStore = useGlobalSettingsStore()
@@ -57,7 +61,7 @@ const runtimeUnavailable = computed(
['blocked_by_policy', 'load_failed'].includes(runtimeStatus.value || '') || ['blocked_by_policy', 'load_failed'].includes(runtimeStatus.value || '') ||
(!props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || '')), (!props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || '')),
) )
const runtimeActionsBlocked = computed(() => runtimePending.value || runtimeUnavailable.value) const runtimeActionsBlocked = computed(() => props.installing || runtimePending.value || runtimeUnavailable.value)
const runtimePendingStatusKeys: Partial<Record<NonNullable<Plugin['runtime_status']>, string>> = { const runtimePendingStatusKeys: Partial<Record<NonNullable<Plugin['runtime_status']>, string>> = {
source_missing: 'plugin.sourceRestoring', source_missing: 'plugin.sourceRestoring',
dependency_pending: 'plugin.dependencyInstalling', dependency_pending: 'plugin.dependencyInstalling',
@@ -73,6 +77,7 @@ const runtimeUnavailableStatusKeys: Partial<Record<NonNullable<Plugin['runtime_s
const showRuntimeStatusDot = computed(() => !runtimeStatus.value || runtimeStatus.value === 'active') const showRuntimeStatusDot = computed(() => !runtimeStatus.value || runtimeStatus.value === 'active')
const runtimeStatusDotColor = computed(() => (props.plugin?.state ? 'success' : 'secondary')) const runtimeStatusDotColor = computed(() => (props.plugin?.state ? 'success' : 'secondary'))
const runtimeStatusText = computed(() => { const runtimeStatusText = computed(() => {
if (props.installing) return t('plugin.installingPlugin')
const status = runtimeStatus.value const status = runtimeStatus.value
const statusKey = status const statusKey = status
? (runtimePending.value ? runtimePendingStatusKeys : runtimeUnavailableStatusKeys)[status] ? (runtimePending.value ? runtimePendingStatusKeys : runtimeUnavailableStatusKeys)[status]
@@ -708,13 +713,13 @@ watch(
</div> </div>
</div> </div>
<div <div
v-if="runtimePending || runtimeUnavailable" v-if="props.installing || runtimePending || runtimeUnavailable"
class="plugin-card__runtime-state" class="plugin-card__runtime-state"
:class="{ 'plugin-card__runtime-state--error': runtimeUnavailable }" :class="{ 'plugin-card__runtime-state--error': runtimeUnavailable }"
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
<VProgressCircular v-if="runtimePending" indeterminate size="22" width="2" /> <VProgressCircular v-if="props.installing || runtimePending" indeterminate size="22" width="2" />
<VIcon <VIcon
v-else v-else
:icon="runtimeStatus === 'blocked_by_policy' ? 'mdi-shield-lock-outline' : 'mdi-alert-circle-outline'" :icon="runtimeStatus === 'blocked_by_policy' ? 'mdi-shield-lock-outline' : 'mdi-alert-circle-outline'"
@@ -17,6 +17,7 @@ interface Props {
showRemoveButton?: boolean showRemoveButton?: boolean
sortable?: boolean sortable?: boolean
runtimeSettling?: boolean runtimeSettling?: boolean
installing?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -25,6 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
showRemoveButton: false, showRemoveButton: false,
sortable: false, sortable: false,
runtimeSettling: false, runtimeSettling: false,
installing: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -111,6 +113,7 @@ function handleDropToFolder(event: DragEvent) {
:action="pluginActions[item.id] || false" :action="pluginActions[item.id] || false"
:sortable="sortable" :sortable="sortable"
:runtime-settling="runtimeSettling" :runtime-settling="runtimeSettling"
:installing="installing"
@remove="$emit('refreshData')" @remove="$emit('refreshData')"
@save="$emit('refreshData')" @save="$emit('refreshData')"
@rating="$emit('rating', $event)" @rating="$emit('rating', $event)"
@@ -395,6 +395,19 @@ describe('PluginCard lifecycle actions', () => {
expect(emitted().actionDone).toHaveLength(1) expect(emitted().actionDone).toHaveLength(1)
}) })
it('keeps the card in installation progress after the runtime becomes active', async () => {
const pending = await renderWithProviders(PluginCard, {
props: {
installing: true,
plugin: { ...plugin, runtime_status: 'active' },
},
})
expect(screen.getByText('正在安装插件...')).toBeInTheDocument()
await fireEvent.click(pending.container.querySelector('.v-card')!)
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
})
it('distinguishes a running recovery from a settled unavailable plugin', async () => { it('distinguishes a running recovery from a settled unavailable plugin', async () => {
const recovering = await renderWithProviders(PluginCard, { const recovering = await renderWithProviders(PluginCard, {
props: { props: {
@@ -37,7 +37,9 @@ const props = defineProps({
count: Number, count: Number,
// 搜索入口交由列表页接管安装,以便先关闭详情并显示插件级加载状态。 // 搜索入口交由列表页接管安装,以便先关闭详情并显示插件级加载状态。
installHandler: { installHandler: {
type: Function as PropType<(releaseVersion?: string, repoUrl?: string) => unknown>, type: Function as PropType<
(releaseVersion?: string, repoUrl?: string, sourceOptions?: PluginSourceOptions) => unknown
>,
default: undefined, default: undefined,
}, },
}) })
@@ -77,10 +79,13 @@ const sourceChanging = ref(false)
const imageLoadError = ref(false) const imageLoadError = ref(false)
const onlineSourceCandidates = computed(() => const onlineSourceCandidates = computed(() =>
(sourceOptions.value?.candidates || []).filter( (sourceOptions.value?.candidates || [])
.filter(
(candidate): candidate is PluginSourceCandidate & { repo_url: string; source_key: string } => (candidate): candidate is PluginSourceCandidate & { repo_url: string; source_key: string } =>
candidate.source_type !== 'local' && Boolean(candidate.repo_url && candidate.source_key), candidate.source_type !== 'local' && Boolean(candidate.repo_url && candidate.source_key),
), )
// 官方仓库是默认可信来源,在所有选源入口中始终置顶。
.sort((left, right) => Number(right.source_type === 'official') - Number(left.source_type === 'official')),
) )
const sourceNeedsSelection = computed(() => !isInstalled.value && sourceOptions.value?.selection_status === 'conflict') const sourceNeedsSelection = computed(() => !isInstalled.value && sourceOptions.value?.selection_status === 'conflict')
const selectedInstallSource = computed(() => const selectedInstallSource = computed(() =>
@@ -167,10 +172,15 @@ async function loadPluginSourceOptions(force = false) {
const options = await getPluginSourceOptions(props.plugin.id, force) const options = await getPluginSourceOptions(props.plugin.id, force)
sourceOptions.value = options sourceOptions.value = options
const installSelectionStillExists = onlineSourceCandidates.value.some( const installCandidates = onlineSourceCandidates.value
const installSelectionStillExists = installCandidates.some(
candidate => candidate.source_key === selectedInstallSourceKey.value, candidate => candidate.source_key === selectedInstallSourceKey.value,
) )
if (!installSelectionStillExists) selectedInstallSourceKey.value = '' if (!installSelectionStillExists) {
const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official')
selectedInstallSourceKey.value =
!isInstalled.value && options.selection_status === 'conflict' ? officialCandidate?.source_key || '' : ''
}
const changeSelectionStillExists = sourceActionCandidates.value.some( const changeSelectionStillExists = sourceActionCandidates.value.some(
candidate => candidate.source_key === selectedChangeSourceKey.value, candidate => candidate.source_key === selectedChangeSourceKey.value,
@@ -316,7 +326,7 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
versionHistoryDialogController?.close() versionHistoryDialogController?.close()
versionHistoryDialogController = null versionHistoryDialogController = null
visible.value = false visible.value = false
await props.installHandler(releaseVersion, selectedRepoUrl) await props.installHandler(releaseVersion, selectedRepoUrl, sourceOptions.value || undefined)
return return
} }
@@ -526,7 +536,20 @@ onUnmounted(() => {
<dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity"> <dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity">
<div> <div>
<dt>{{ t('plugin.trustedUpdateSource') }}</dt> <dt>{{ t('plugin.trustedUpdateSource') }}</dt>
<dd>{{ trustedSourceLabel() }}</dd> <dd>
<span class="plugin-market-detail-source__identity-value">
<VChip
v-if="sourceOptions.identity.trusted_source_type === 'official'"
size="x-small"
color="primary"
variant="tonal"
prepend-icon="mdi-shield-check"
>
{{ t('plugin.sourceOfficial') }}
</VChip>
{{ trustedSourceLabel() }}
</span>
</dd>
</div> </div>
<div v-if="sourceOptions.identity.payload_source_type === 'local'"> <div v-if="sourceOptions.identity.payload_source_type === 'local'">
<dt>{{ t('plugin.currentPayloadSource') }}</dt> <dt>{{ t('plugin.currentPayloadSource') }}</dt>
@@ -557,8 +580,19 @@ onUnmounted(() => {
> >
<template #label> <template #label>
<span class="plugin-market-detail-source__choice-label"> <span class="plugin-market-detail-source__choice-label">
<span class="plugin-market-detail-source__choice-title">
<VChip
v-if="candidate.source_type === 'official'"
size="x-small"
color="primary"
variant="tonal"
prepend-icon="mdi-shield-check"
>
{{ t('plugin.sourceOfficial') }}
</VChip>
<strong>{{ sourceCandidateLabel(candidate) }}</strong> <strong>{{ sourceCandidateLabel(candidate) }}</strong>
<span </span>
<span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span >v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span
> >
</span> </span>
@@ -585,8 +619,19 @@ onUnmounted(() => {
> >
<template #label> <template #label>
<span class="plugin-market-detail-source__choice-label"> <span class="plugin-market-detail-source__choice-label">
<span class="plugin-market-detail-source__choice-title">
<VChip
v-if="candidate.source_type === 'official'"
size="x-small"
color="primary"
variant="tonal"
prepend-icon="mdi-shield-check"
>
{{ t('plugin.sourceOfficial') }}
</VChip>
<strong>{{ sourceCandidateLabel(candidate) }}</strong> <strong>{{ sourceCandidateLabel(candidate) }}</strong>
<span </span>
<span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} · >v{{ candidate.plugin_version || '-' }} ·
{{ candidate.package_generation.toUpperCase() }}</span {{ candidate.package_generation.toUpperCase() }}</span
> >
@@ -752,6 +797,19 @@ onUnmounted(() => {
text-align: end; text-align: end;
} }
.plugin-market-detail-source__identity-value,
.plugin-market-detail-source__choice-title {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 0.375rem;
}
.plugin-market-detail-source__choice-title {
justify-content: flex-start;
}
.plugin-market-detail-source__choices :deep(.v-selection-control), .plugin-market-detail-source__choices :deep(.v-selection-control),
.plugin-market-detail-source__change :deep(.v-selection-control) { .plugin-market-detail-source__change :deep(.v-selection-control) {
min-height: 2.75rem; min-height: 2.75rem;
@@ -769,7 +827,7 @@ onUnmounted(() => {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.plugin-market-detail-source__choice-label span { .plugin-market-detail-source__choice-meta {
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem; font-size: 0.75rem;
} }
@@ -141,6 +141,7 @@ describe('PluginMarketDetailDialog', () => {
selection_status: 'conflict', selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择', selection_reason: '未安装插件存在多个在线来源,不能静默选择',
candidates: [ candidates: [
defaultSourceOptions.candidates[0],
{ {
source_type: 'official', source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins', source_key: 'github:jxxghp/moviepilot-plugins',
@@ -148,7 +149,6 @@ describe('PluginMarketDetailDialog', () => {
package_generation: 'v3', package_generation: 'v3',
plugin_version: '1.0.0', plugin_version: '1.0.0',
}, },
defaultSourceOptions.candidates[0],
], ],
} satisfies PluginSourceOptions) } satisfies PluginSourceOptions)
} }
@@ -158,10 +158,11 @@ describe('PluginMarketDetailDialog', () => {
expect(await screen.findByText('未安装插件存在多个在线来源,不能静默选择')).toBeInTheDocument() expect(await screen.findByText('未安装插件存在多个在线来源,不能静默选择')).toBeInTheDocument()
const installButton = screen.getByRole('button', { name: '安装到本地' }) const installButton = screen.getByRole('button', { name: '安装到本地' })
expect(installButton).toBeDisabled()
await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins'))
expect(installButton).toBeEnabled() expect(installButton).toBeEnabled()
expect(screen.getByText('官方')).toBeInTheDocument()
expect(screen.getAllByRole('radio')[0]).toHaveAttribute('value', 'github:jxxghp/moviepilot-plugins')
expect(screen.getAllByRole('radio')[0]).toBeChecked()
await fireEvent.click(installButton) await fireEvent.click(installButton)
await waitFor(() => { await waitFor(() => {
@@ -208,6 +209,7 @@ describe('PluginMarketDetailDialog', () => {
expect(await screen.findByText('自动更新来源')).toBeInTheDocument() expect(await screen.findByText('自动更新来源')).toBeInTheDocument()
expect(screen.getByText('jxxghp/moviepilot-plugins')).toBeInTheDocument() expect(screen.getByText('jxxghp/moviepilot-plugins')).toBeInTheDocument()
expect(screen.getByText('官方')).toBeInTheDocument()
expect(screen.getByText('当前载荷')).toBeInTheDocument() expect(screen.getByText('当前载荷')).toBeInTheDocument()
expect(screen.getByText('本地')).toBeInTheDocument() expect(screen.getByText('本地')).toBeInTheDocument()
@@ -502,7 +504,7 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' })) await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
expect(installHandler).toHaveBeenCalledWith(undefined, undefined) expect(installHandler).toHaveBeenCalledWith(undefined, undefined, defaultSourceOptions)
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything()) expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined() expect(emitted().install).toBeUndefined()
expect(emitted()['update:modelValue']).toContainEqual([false]) expect(emitted()['update:modelValue']).toContainEqual([false])
+1
View File
@@ -3857,6 +3857,7 @@ export default {
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.', 'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
local: 'Local', local: 'Local',
source: 'Plugin Source', source: 'Plugin Source',
sourceOfficial: 'Official',
sourceUnknown: 'Unknown source', sourceUnknown: 'Unknown source',
sourceUnbound: 'Not bound', sourceUnbound: 'Not bound',
sourceLoadFailed: 'Unable to load plugin sources. Try again later.', sourceLoadFailed: 'Unable to load plugin sources. Try again later.',
+1
View File
@@ -3794,6 +3794,7 @@ export default {
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。', '是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地', local: '本地',
source: '插件来源', source: '插件来源',
sourceOfficial: '官方',
sourceUnknown: '未知来源', sourceUnknown: '未知来源',
sourceUnbound: '尚未绑定', sourceUnbound: '尚未绑定',
sourceLoadFailed: '无法读取插件来源,请稍后重试', sourceLoadFailed: '无法读取插件来源,请稍后重试',
+1
View File
@@ -3793,6 +3793,7 @@ export default {
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。', '是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地', local: '本地',
source: '插件來源', source: '插件來源',
sourceOfficial: '官方',
sourceUnknown: '未知來源', sourceUnknown: '未知來源',
sourceUnbound: '尚未綁定', sourceUnbound: '尚未綁定',
sourceLoadFailed: '無法讀取插件來源,請稍後重試', sourceLoadFailed: '無法讀取插件來源,請稍後重試',
+46 -18
View File
@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
import api from '@/api' import api from '@/api'
import { getApiBusinessErrorMessage } from '@/api/client' import { getApiBusinessErrorMessage } from '@/api/client'
import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource' import { getPluginSourceOptions, installPluginFromSource } from '@/api/pluginSource'
import type { Plugin, PluginRating } from '@/api/types' import type { Plugin, PluginRating, PluginSourceOptions } from '@/api/types'
import NoDataFound from '@/components/states/NoDataFound.vue' import NoDataFound from '@/components/states/NoDataFound.vue'
import { getPluginTabs } from '@/router/i18n-menu' import { getPluginTabs } from '@/router/i18n-menu'
import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton' import { useDynamicButton, type DynamicButtonMenuItem } from '@/composables/useDynamicButton'
@@ -846,7 +846,12 @@ function pluginDialogClose() {
} }
// //
async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: string) { async function installPlugin(
item: Plugin,
releaseVersion?: string,
repoUrl?: string,
inspectedSourceOptions?: PluginSourceOptions,
) {
const pluginId = item?.id const pluginId = item?.id
if (!pluginId || installingPluginIds.value.has(pluginId)) { if (!pluginId || installingPluginIds.value.has(pluginId)) {
return return
@@ -867,7 +872,7 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
let useExplicitSource = false let useExplicitSource = false
try { try {
const sourceOptions = await getPluginSourceOptions(pluginId) const sourceOptions = inspectedSourceOptions || (await getPluginSourceOptions(pluginId))
if (sourceOptions.selection_status === 'conflict') { if (sourceOptions.selection_status === 'conflict') {
if (!repoUrl) { if (!repoUrl) {
releaseInstallReservation() releaseInstallReservation()
@@ -894,6 +899,9 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
// Gateway // Gateway
} }
const wasInMarket = uninstalledList.value.some(plugin => plugin.id === pluginId)
if (wasInMarket) removeInstalledPluginFromMarket(pluginId)
const previousIndex = dataList.value.findIndex(plugin => plugin.id === item.id) const previousIndex = dataList.value.findIndex(plugin => plugin.id === item.id)
const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined const previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
sortMode.value = false sortMode.value = false
@@ -909,13 +917,12 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
...item, ...item,
installed: true, installed: true,
state: false, state: false,
runtime_status: 'source_missing', runtime_status: undefined,
}, },
] ]
activeTab.value = 'installed' activeTab.value = 'installed'
pluginDialogClose() pluginDialogClose()
let installed = false
try { try {
if (useExplicitSource && repoUrl) { if (useExplicitSource && repoUrl) {
await installPluginFromSource(pluginId, { await installPluginFromSource(pluginId, {
@@ -932,8 +939,6 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
feedback: 'silent', feedback: 'silent',
}) })
} }
installed = true
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name })) $toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
await fetchInstalledPlugins({ silent: true }) await fetchInstalledPlugins({ silent: true })
if (userStore.superUser) await pluginRuntimeStore.refresh() if (userStore.superUser) await pluginRuntimeStore.refresh()
@@ -947,6 +952,7 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
const nextData = dataList.value.filter(plugin => plugin.id !== pluginId) const nextData = dataList.value.filter(plugin => plugin.id !== pluginId)
if (previousPlugin) nextData.splice(Math.min(previousIndex, nextData.length), 0, previousPlugin) if (previousPlugin) nextData.splice(Math.min(previousIndex, nextData.length), 0, previousPlugin)
dataList.value = nextData dataList.value = nextData
if (wasInMarket) restorePluginToMarket(item)
if (installScrollPluginId.value === pluginId) installScrollPluginId.value = null if (installScrollPluginId.value === pluginId) installScrollPluginId.value = null
console.error(error) console.error(error)
$toast.error( $toast.error(
@@ -958,13 +964,11 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
// //
void fetchInstalledPlugins({ silent: true }) void fetchInstalledPlugins({ silent: true })
} finally { } finally {
if (!installed) {
const pending = new Set(installingPluginIds.value) const pending = new Set(installingPluginIds.value)
pending.delete(pluginId) pending.delete(pluginId)
installingPluginIds.value = pending installingPluginIds.value = pending
} }
} }
}
/** 打开未安装插件的市场详情,确认后才进入安装流程。 */ /** 打开未安装插件的市场详情,确认后才进入安装流程。 */
function openPluginMarketDetail(item: Plugin) { function openPluginMarketDetail(item: Plugin) {
@@ -973,7 +977,8 @@ function openPluginMarketDetail(item: Plugin) {
{ {
plugin: item, plugin: item,
count: PluginStatistics.value[item.id || '0'], count: PluginStatistics.value[item.id || '0'],
installHandler: (releaseVersion?: string, repoUrl?: string) => installPlugin(item, releaseVersion, repoUrl), installHandler: (releaseVersion?: string, repoUrl?: string, sourceOptions?: PluginSourceOptions) =>
installPlugin(item, releaseVersion, repoUrl, sourceOptions),
}, },
{ {
install: pluginInstalled, install: pluginInstalled,
@@ -1052,12 +1057,6 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}): Pro
const optimisticPlugins = dataList.value.filter( const optimisticPlugins = dataList.value.filter(
plugin => installingPluginIds.value.has(plugin.id) && !serverIds.has(plugin.id), plugin => installingPluginIds.value.has(plugin.id) && !serverIds.has(plugin.id),
) )
if (installingPluginIds.value.size > 0) {
const pending = new Set(installingPluginIds.value)
serverIds.forEach(pluginId => pending.delete(pluginId))
installingPluginIds.value = pending
}
mergeRatingsIntoPlugins(mergedPlugins) mergeRatingsIntoPlugins(mergedPlugins)
dataList.value = [...mergedPlugins, ...optimisticPlugins] dataList.value = [...mergedPlugins, ...optimisticPlugins]
mergeMarketMetadataIntoInstalled() mergeMarketMetadataIntoInstalled()
@@ -1108,6 +1107,26 @@ function mergeMarketMetadataIntoInstalled() {
}) })
} }
/** 从当前市场快照隐藏插件,保留用户的排序、分页和滚动位置。 */
function removeInstalledPluginFromMarket(pluginId: string) {
//
marketWriterGeneration += 1
uninstalledList.value = uninstalledList.value.filter(plugin => plugin.id !== pluginId)
marketList.value = marketList.value.filter(plugin => plugin.id !== pluginId)
}
/** 安装失败时恢复被隐藏的市场条目,避免失败被误显示为已安装。 */
function restorePluginToMarket(plugin: Plugin) {
marketWriterGeneration += 1
if (!uninstalledList.value.some(item => item.id === plugin.id)) {
uninstalledList.value = [...uninstalledList.value, plugin]
}
if (!marketList.value.some(item => item.id === plugin.id)) {
marketList.value = [...marketList.value, plugin]
}
initOptions(plugin)
}
interface PluginMarketMetrics { interface PluginMarketMetrics {
statistics?: { [key: string]: number } statistics?: { [key: string]: number }
ratings?: { [key: string]: PluginRating } ratings?: { [key: string]: PluginRating }
@@ -1124,7 +1143,9 @@ function applyMarketSnapshot(marketResponse: Plugin[], metrics?: CompletePluginM
mergeRatingsIntoPlugins(marketResponse, metrics?.ratings) mergeRatingsIntoPlugins(marketResponse, metrics?.ratings)
uninstalledList.value = marketResponse uninstalledList.value = marketResponse
mergeMarketMetadataIntoInstalled() mergeMarketMetadataIntoInstalled()
marketList.value = uninstalledList.value.filter(item => !(item.has_update && item.installed)) marketList.value = uninstalledList.value.filter(
item => !installingPluginIds.value.has(item.id) && !(item.has_update && item.installed),
)
authorFilterOptions.value = [] authorFilterOptions.value = []
labelFilterOptions.value = [] labelFilterOptions.value = []
repoFilterOptions.value = [] repoFilterOptions.value = []
@@ -2204,6 +2225,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics" :plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions" :plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(element.id)" :runtime-settling="isPluginRuntimeSettling(element.id)"
:installing="installingPluginIds.has(element.id)"
:sortable="true" :sortable="true"
@open-folder="openFolder" @open-folder="openFolder"
@delete-folder="deleteFolder" @delete-folder="deleteFolder"
@@ -2234,6 +2256,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics" :plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions" :plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(item.id)" :runtime-settling="isPluginRuntimeSettling(item.id)"
:installing="installingPluginIds.has(item.id)"
:sortable="false" :sortable="false"
@open-folder="openFolder" @open-folder="openFolder"
@delete-folder="deleteFolder" @delete-folder="deleteFolder"
@@ -2270,6 +2293,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics" :plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions" :plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(element.id)" :runtime-settling="isPluginRuntimeSettling(element.id)"
:installing="installingPluginIds.has(element.id)"
:sortable="true" :sortable="true"
:show-remove-button="true" :show-remove-button="true"
@refresh-data="refreshData" @refresh-data="refreshData"
@@ -2296,6 +2320,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics" :plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions" :plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(item.id)" :runtime-settling="isPluginRuntimeSettling(item.id)"
:installing="installingPluginIds.has(item.id)"
:sortable="false" :sortable="false"
:show-remove-button="true" :show-remove-button="true"
@refresh-data="refreshData" @refresh-data="refreshData"
@@ -2365,7 +2390,10 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
<PluginAppCard <PluginAppCard
:plugin="item" :plugin="item"
:count="PluginStatistics[item.id || '0']" :count="PluginStatistics[item.id || '0']"
:install-handler="(releaseVersion, repoUrl) => installPlugin(item, releaseVersion, repoUrl)" :install-handler="
(releaseVersion, repoUrl, sourceOptions) =>
installPlugin(item, releaseVersion, repoUrl, sourceOptions)
"
@install="pluginInstalled" @install="pluginInstalled"
/> />
</template> </template>
@@ -146,6 +146,7 @@ const PluginMixedSortCardStub = defineComponent({
item: { type: Object as PropType<Record<string, unknown>>, required: true }, item: { type: Object as PropType<Record<string, unknown>>, required: true },
pluginStatistics: { type: Object as PropType<Record<string, number>>, default: () => ({}) }, pluginStatistics: { type: Object as PropType<Record<string, number>>, default: () => ({}) },
runtimeSettling: Boolean, runtimeSettling: Boolean,
installing: Boolean,
sortable: Boolean, sortable: Boolean,
}, },
emits: [ emits: [
@@ -186,6 +187,7 @@ const PluginMixedSortCardStub = defineComponent({
type === 'plugin' ? h('output', { 'aria-label': `repo-${id}` }, data?.repo_url || '') : null, type === 'plugin' ? h('output', { 'aria-label': `repo-${id}` }, data?.repo_url || '') : null,
type === 'plugin' ? h('output', { 'aria-label': `runtime-${id}` }, data?.runtime_status || '') : null, type === 'plugin' ? h('output', { 'aria-label': `runtime-${id}` }, data?.runtime_status || '') : null,
type === 'plugin' ? h('output', { 'aria-label': `settling-${id}` }, String(props.runtimeSettling)) : null, type === 'plugin' ? h('output', { 'aria-label': `settling-${id}` }, String(props.runtimeSettling)) : null,
type === 'plugin' ? h('output', { 'aria-label': `installing-${id}` }, String(props.installing)) : null,
type === 'plugin' type === 'plugin'
? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? '')) ? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? ''))
: null, : null,
@@ -1271,10 +1273,42 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed')) await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed'))
expect(await screen.findByText('plugin:市场安装插件')).toBeInTheDocument() expect(await screen.findByText('plugin:市场安装插件')).toBeInTheDocument()
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument() expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
getHeaderConfig().modelValue.value = 'market'
await nextTick()
expect(screen.queryByTestId('market-MarketInstall')).not.toBeInTheDocument()
installGate.resolve() installGate.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 市场安装插件 安装成功!')) await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 市场安装插件 安装成功!'))
await waitForRequestsToFinish() await waitForRequestsToFinish()
await waitFor(() => expect(screen.queryByTestId('market-MarketInstall')).not.toBeInTheDocument())
})
it('restores a market plugin after an installation failure', async () => {
const installGate = createDeferred<void>()
const target = createPlugin({ id: 'FailedMarketInstall', plugin_name: '失败市场插件' })
await renderList({ installed: () => [], market: () => [target] })
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('FailedMarketInstall'), async () => {
await installGate.promise
return HttpResponse.json({ message: '安装失败' }, { status: 500 })
}),
)
getHeaderConfig().modelValue.value = 'market'
await nextTick()
await fireEvent.click(screen.getByRole('button', { name: 'installed-FailedMarketInstall' }))
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed'))
getHeaderConfig().modelValue.value = 'market'
await nextTick()
expect(screen.queryByTestId('market-FailedMarketInstall')).not.toBeInTheDocument()
installGate.resolve()
await waitFor(() => expect(mocks.toastError).toHaveBeenCalled())
await waitFor(() => expect(screen.getByTestId('market-FailedMarketInstall')).toBeInTheDocument())
}) })
}) })
@@ -1370,6 +1404,51 @@ describe('PluginCardListView search installation', () => {
await waitForRequestsToFinish() await waitForRequestsToFinish()
}) })
it('reuses the inspected source snapshot when installing from the detail dialog', async () => {
let sourceOptionRequests = 0
let installRequests = 0
const target = createPlugin({ id: 'InspectedPlugin', plugin_name: '已检查来源插件' })
const sourceOptions: PluginSourceOptions = {
plugin_id: 'InspectedPlugin',
inventory_complete: true,
selection_status: 'selected',
selection_reason: '',
identity: null,
candidates: [
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins',
package_generation: 'v3',
plugin_version: '1.0.0',
},
],
}
await renderList({
market: () => [target],
sourceOptions: () => {
sourceOptionRequests += 1
return sourceOptions
},
})
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('InspectedPlugin'), () => {
installRequests += 1
return apiJson(null)
}),
)
getDynamicButtonConfig().onClick()
await getDialogEvents()['open-plugin'](target)
await getDialogProps().installHandler?.(undefined, undefined, sourceOptions)
expect(sourceOptionRequests).toBe(0)
expect(installRequests).toBe(1)
expect(getHeaderConfig().modelValue.value).toBe('installed')
await waitForRequestsToFinish()
})
it('opens source selection instead of silently installing a conflicting plugin ID', async () => { it('opens source selection instead of silently installing a conflicting plugin ID', async () => {
let installRequests = 0 let installRequests = 0
const target = createPlugin({ id: 'ConflictPlugin', plugin_name: '重名插件' }) const target = createPlugin({ id: 'ConflictPlugin', plugin_name: '重名插件' })
@@ -1419,6 +1498,7 @@ describe('PluginCardListView search installation', () => {
it('shows a per-plugin loading card while installation is still running', async () => { it('shows a per-plugin loading card while installation is still running', async () => {
const installGate = createDeferred<void>() const installGate = createDeferred<void>()
const installStarted = createDeferred<void>()
let installed = false let installed = false
const target = createPlugin({ id: 'PendingPlugin', plugin_name: '后台安装插件' }) const target = createPlugin({ id: 'PendingPlugin', plugin_name: '后台安装插件' })
await renderList({ await renderList({
@@ -1438,8 +1518,9 @@ describe('PluginCardListView search installation', () => {
await waitForRequestsToFinish() await waitForRequestsToFinish()
server.use( server.use(
http.get(apiUrls.install('PendingPlugin'), async () => { http.get(apiUrls.install('PendingPlugin'), async () => {
await installGate.promise
installed = true installed = true
installStarted.resolve()
await installGate.promise
return apiJson(null) return apiJson(null)
}), }),
) )
@@ -1450,13 +1531,20 @@ describe('PluginCardListView search installation', () => {
expect(await screen.findByText('plugin:后台安装插件')).toBeInTheDocument() expect(await screen.findByText('plugin:后台安装插件')).toBeInTheDocument()
expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument() expect(document.querySelector('[data-scroll-to-index="0"]')).toBeInTheDocument()
expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('source_missing') expect(screen.getByLabelText('runtime-PendingPlugin')).toBeEmptyDOMElement()
expect(screen.getByLabelText('settling-PendingPlugin')).toHaveTextContent('true') expect(screen.getByLabelText('settling-PendingPlugin')).toHaveTextContent('true')
expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('true')
expect(mocks.toastSuccess).not.toHaveBeenCalled()
await installStarted.promise
await mocks.keepAliveHandler?.({ silent: true })
expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('true')
expect(mocks.toastSuccess).not.toHaveBeenCalled() expect(mocks.toastSuccess).not.toHaveBeenCalled()
installGate.resolve() installGate.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 后台安装插件 安装成功!')) await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 后台安装插件 安装成功!'))
await waitFor(() => expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('active')) await waitFor(() => expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('active'))
await waitFor(() => expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('false'))
await waitForRequestsToFinish() await waitForRequestsToFinish()
}) })