mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
fix(plugin): 完善市场安装状态与官方来源选择 (#723)
This commit is contained in:
+12
-12
@@ -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',
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 || [])
|
||||||
(candidate): candidate is PluginSourceCandidate & { repo_url: string; source_key: string } =>
|
.filter(
|
||||||
candidate.source_type !== 'local' && Boolean(candidate.repo_url && candidate.source_key),
|
(candidate): candidate is PluginSourceCandidate & { repo_url: string; source_key: string } =>
|
||||||
),
|
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">
|
||||||
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
|
<span class="plugin-market-detail-source__choice-title">
|
||||||
<span
|
<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>
|
||||||
|
</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">
|
||||||
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
|
<span class="plugin-market-detail-source__choice-title">
|
||||||
<span
|
<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>
|
||||||
|
</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])
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -3794,6 +3794,7 @@ export default {
|
|||||||
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
||||||
local: '本地',
|
local: '本地',
|
||||||
source: '插件来源',
|
source: '插件来源',
|
||||||
|
sourceOfficial: '官方',
|
||||||
sourceUnknown: '未知来源',
|
sourceUnknown: '未知来源',
|
||||||
sourceUnbound: '尚未绑定',
|
sourceUnbound: '尚未绑定',
|
||||||
sourceLoadFailed: '无法读取插件来源,请稍后重试',
|
sourceLoadFailed: '无法读取插件来源,请稍后重试',
|
||||||
|
|||||||
@@ -3793,6 +3793,7 @@ export default {
|
|||||||
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
||||||
local: '本地',
|
local: '本地',
|
||||||
source: '插件來源',
|
source: '插件來源',
|
||||||
|
sourceOfficial: '官方',
|
||||||
sourceUnknown: '未知來源',
|
sourceUnknown: '未知來源',
|
||||||
sourceUnbound: '尚未綁定',
|
sourceUnbound: '尚未綁定',
|
||||||
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
|
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
|
||||||
|
|||||||
@@ -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,11 +964,9 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user