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 globals from 'globals'
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}']
@@ -91,17 +94,14 @@ const vueConfigs = pluginVue.configs['flat/essential'].map(config => ({
}))
export default defineConfig([
globalIgnores([
'**/node_modules/**',
'**/dist/**',
'**/dev-dist/**',
'**/coverage/**',
'**/.worktrees/**',
'**/vite.config.*.timestamp-*.mjs',
'public/plugin_icon/**',
'src/@iconify/**',
'**/*.d.ts',
]),
includeIgnoreFile(gitignorePath, {
gitignoreResolution: true,
name: 'moviepilot/gitignore',
}),
globalIgnores(
['**/.worktrees/**', '**/vite.config.*.timestamp-*.mjs', 'src/@iconify/**', '**/*.d.ts'],
'moviepilot/eslint-only-ignores',
),
{
...js.configs.recommended,
name: 'moviepilot/javascript',
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import api from '@/api'
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 { useGlobalSettingsStore } from '@/stores'
import { usePluginCardAccent } from '@/composables/usePluginCardAccent'
@@ -18,7 +18,7 @@ const PluginVersionHistoryDialog = defineAsyncComponent(
)
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({
+8 -3
View File
@@ -37,6 +37,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
installing: {
type: Boolean,
default: false,
},
})
const globalSettingsStore = useGlobalSettingsStore()
@@ -57,7 +61,7 @@ const runtimeUnavailable = computed(
['blocked_by_policy', 'load_failed'].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>> = {
source_missing: 'plugin.sourceRestoring',
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 runtimeStatusDotColor = computed(() => (props.plugin?.state ? 'success' : 'secondary'))
const runtimeStatusText = computed(() => {
if (props.installing) return t('plugin.installingPlugin')
const status = runtimeStatus.value
const statusKey = status
? (runtimePending.value ? runtimePendingStatusKeys : runtimeUnavailableStatusKeys)[status]
@@ -708,13 +713,13 @@ watch(
</div>
</div>
<div
v-if="runtimePending || runtimeUnavailable"
v-if="props.installing || runtimePending || runtimeUnavailable"
class="plugin-card__runtime-state"
:class="{ 'plugin-card__runtime-state--error': runtimeUnavailable }"
role="status"
aria-live="polite"
>
<VProgressCircular v-if="runtimePending" indeterminate size="22" width="2" />
<VProgressCircular v-if="props.installing || runtimePending" indeterminate size="22" width="2" />
<VIcon
v-else
:icon="runtimeStatus === 'blocked_by_policy' ? 'mdi-shield-lock-outline' : 'mdi-alert-circle-outline'"
@@ -17,6 +17,7 @@ interface Props {
showRemoveButton?: boolean
sortable?: boolean
runtimeSettling?: boolean
installing?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -25,6 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
showRemoveButton: false,
sortable: false,
runtimeSettling: false,
installing: false,
})
const emit = defineEmits<{
@@ -111,6 +113,7 @@ function handleDropToFolder(event: DragEvent) {
:action="pluginActions[item.id] || false"
:sortable="sortable"
:runtime-settling="runtimeSettling"
:installing="installing"
@remove="$emit('refreshData')"
@save="$emit('refreshData')"
@rating="$emit('rating', $event)"
@@ -395,6 +395,19 @@ describe('PluginCard lifecycle actions', () => {
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 () => {
const recovering = await renderWithProviders(PluginCard, {
props: {
@@ -37,7 +37,9 @@ const props = defineProps({
count: Number,
// 搜索入口交由列表页接管安装,以便先关闭详情并显示插件级加载状态。
installHandler: {
type: Function as PropType<(releaseVersion?: string, repoUrl?: string) => unknown>,
type: Function as PropType<
(releaseVersion?: string, repoUrl?: string, sourceOptions?: PluginSourceOptions) => unknown
>,
default: undefined,
},
})
@@ -77,10 +79,13 @@ const sourceChanging = ref(false)
const imageLoadError = ref(false)
const onlineSourceCandidates = computed(() =>
(sourceOptions.value?.candidates || []).filter(
(candidate): candidate is PluginSourceCandidate & { repo_url: string; source_key: string } =>
candidate.source_type !== 'local' && Boolean(candidate.repo_url && candidate.source_key),
),
(sourceOptions.value?.candidates || [])
.filter(
(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 selectedInstallSource = computed(() =>
@@ -167,10 +172,15 @@ async function loadPluginSourceOptions(force = false) {
const options = await getPluginSourceOptions(props.plugin.id, force)
sourceOptions.value = options
const installSelectionStillExists = onlineSourceCandidates.value.some(
const installCandidates = onlineSourceCandidates.value
const installSelectionStillExists = installCandidates.some(
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(
candidate => candidate.source_key === selectedChangeSourceKey.value,
@@ -316,7 +326,7 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
versionHistoryDialogController?.close()
versionHistoryDialogController = null
visible.value = false
await props.installHandler(releaseVersion, selectedRepoUrl)
await props.installHandler(releaseVersion, selectedRepoUrl, sourceOptions.value || undefined)
return
}
@@ -526,7 +536,20 @@ onUnmounted(() => {
<dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity">
<div>
<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 v-if="sourceOptions.identity.payload_source_type === 'local'">
<dt>{{ t('plugin.currentPayloadSource') }}</dt>
@@ -557,8 +580,19 @@ onUnmounted(() => {
>
<template #label>
<span class="plugin-market-detail-source__choice-label">
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
<span
<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>
</span>
<span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span
>
</span>
@@ -585,8 +619,19 @@ onUnmounted(() => {
>
<template #label>
<span class="plugin-market-detail-source__choice-label">
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
<span
<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>
</span>
<span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} ·
{{ candidate.package_generation.toUpperCase() }}</span
>
@@ -752,6 +797,19 @@ onUnmounted(() => {
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__change :deep(.v-selection-control) {
min-height: 2.75rem;
@@ -769,7 +827,7 @@ onUnmounted(() => {
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));
font-size: 0.75rem;
}
@@ -141,6 +141,7 @@ describe('PluginMarketDetailDialog', () => {
selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
candidates: [
defaultSourceOptions.candidates[0],
{
source_type: 'official',
source_key: 'github:jxxghp/moviepilot-plugins',
@@ -148,7 +149,6 @@ describe('PluginMarketDetailDialog', () => {
package_generation: 'v3',
plugin_version: '1.0.0',
},
defaultSourceOptions.candidates[0],
],
} satisfies PluginSourceOptions)
}
@@ -158,10 +158,11 @@ describe('PluginMarketDetailDialog', () => {
expect(await screen.findByText('未安装插件存在多个在线来源,不能静默选择')).toBeInTheDocument()
const installButton = screen.getByRole('button', { name: '安装到本地' })
expect(installButton).toBeDisabled()
await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins'))
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 waitFor(() => {
@@ -208,6 +209,7 @@ describe('PluginMarketDetailDialog', () => {
expect(await screen.findByText('自动更新来源')).toBeInTheDocument()
expect(screen.getByText('jxxghp/moviepilot-plugins')).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: '安装到本地' }))
expect(installHandler).toHaveBeenCalledWith(undefined, undefined)
expect(installHandler).toHaveBeenCalledWith(undefined, undefined, defaultSourceOptions)
expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything())
expect(emitted().install).toBeUndefined()
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.',
local: 'Local',
source: 'Plugin Source',
sourceOfficial: 'Official',
sourceUnknown: 'Unknown source',
sourceUnbound: 'Not bound',
sourceLoadFailed: 'Unable to load plugin sources. Try again later.',
+1
View File
@@ -3794,6 +3794,7 @@ export default {
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地',
source: '插件来源',
sourceOfficial: '官方',
sourceUnknown: '未知来源',
sourceUnbound: '尚未绑定',
sourceLoadFailed: '无法读取插件来源,请稍后重试',
+1
View File
@@ -3793,6 +3793,7 @@ export default {
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地',
source: '插件來源',
sourceOfficial: '官方',
sourceUnknown: '未知來源',
sourceUnbound: '尚未綁定',
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
+49 -21
View File
@@ -3,7 +3,7 @@ import { useToast } from 'vue-toastification'
import api from '@/api'
import { getApiBusinessErrorMessage } from '@/api/client'
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 { getPluginTabs } from '@/router/i18n-menu'
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
if (!pluginId || installingPluginIds.value.has(pluginId)) {
return
@@ -867,7 +872,7 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
let useExplicitSource = false
try {
const sourceOptions = await getPluginSourceOptions(pluginId)
const sourceOptions = inspectedSourceOptions || (await getPluginSourceOptions(pluginId))
if (sourceOptions.selection_status === 'conflict') {
if (!repoUrl) {
releaseInstallReservation()
@@ -894,6 +899,9 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
// 候选查询失败时仍由安装 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 previousPlugin = previousIndex >= 0 ? dataList.value[previousIndex] : undefined
sortMode.value = false
@@ -909,13 +917,12 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
...item,
installed: true,
state: false,
runtime_status: 'source_missing',
runtime_status: undefined,
},
]
activeTab.value = 'installed'
pluginDialogClose()
let installed = false
try {
if (useExplicitSource && repoUrl) {
await installPluginFromSource(pluginId, {
@@ -932,8 +939,6 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
feedback: 'silent',
})
}
installed = true
$toast.success(t('plugin.installSuccess', { name: item?.plugin_name }))
await fetchInstalledPlugins({ silent: true })
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)
if (previousPlugin) nextData.splice(Math.min(previousIndex, nextData.length), 0, previousPlugin)
dataList.value = nextData
if (wasInMarket) restorePluginToMarket(item)
if (installScrollPluginId.value === pluginId) installScrollPluginId.value = null
console.error(error)
$toast.error(
@@ -958,11 +964,9 @@ async function installPlugin(item: Plugin, releaseVersion?: string, repoUrl?: st
// 列表校准不能延迟失败反馈,网络异常时也要立即告诉用户安装事务已回滚。
void fetchInstalledPlugins({ silent: true })
} finally {
if (!installed) {
const pending = new Set(installingPluginIds.value)
pending.delete(pluginId)
installingPluginIds.value = pending
}
const pending = new Set(installingPluginIds.value)
pending.delete(pluginId)
installingPluginIds.value = pending
}
}
@@ -973,7 +977,8 @@ function openPluginMarketDetail(item: Plugin) {
{
plugin: item,
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,
@@ -1052,12 +1057,6 @@ async function fetchInstalledPlugins(context: KeepAliveRefreshContext = {}): Pro
const optimisticPlugins = dataList.value.filter(
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)
dataList.value = [...mergedPlugins, ...optimisticPlugins]
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 {
statistics?: { [key: string]: number }
ratings?: { [key: string]: PluginRating }
@@ -1124,7 +1143,9 @@ function applyMarketSnapshot(marketResponse: Plugin[], metrics?: CompletePluginM
mergeRatingsIntoPlugins(marketResponse, metrics?.ratings)
uninstalledList.value = marketResponse
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 = []
labelFilterOptions.value = []
repoFilterOptions.value = []
@@ -2204,6 +2225,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(element.id)"
:installing="installingPluginIds.has(element.id)"
:sortable="true"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@@ -2234,6 +2256,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(item.id)"
:installing="installingPluginIds.has(item.id)"
:sortable="false"
@open-folder="openFolder"
@delete-folder="deleteFolder"
@@ -2270,6 +2293,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(element.id)"
:installing="installingPluginIds.has(element.id)"
:sortable="true"
:show-remove-button="true"
@refresh-data="refreshData"
@@ -2296,6 +2320,7 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
:plugin-statistics="PluginStatistics"
:plugin-actions="pluginActions"
:runtime-settling="isPluginRuntimeSettling(item.id)"
:installing="installingPluginIds.has(item.id)"
:sortable="false"
:show-remove-button="true"
@refresh-data="refreshData"
@@ -2365,7 +2390,10 @@ function onDragStartPlugin(evt: { oldIndex?: number; item?: HTMLElement }) {
<PluginAppCard
:plugin="item"
: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"
/>
</template>
@@ -146,6 +146,7 @@ const PluginMixedSortCardStub = defineComponent({
item: { type: Object as PropType<Record<string, unknown>>, required: true },
pluginStatistics: { type: Object as PropType<Record<string, number>>, default: () => ({}) },
runtimeSettling: Boolean,
installing: Boolean,
sortable: Boolean,
},
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': `runtime-${id}` }, data?.runtime_status || '') : 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'
? h('output', { 'aria-label': `statistic-${id}` }, String(props.pluginStatistics[id] ?? ''))
: null,
@@ -1271,10 +1273,42 @@ describe('PluginCardListView installed filtering and host callbacks', () => {
await waitFor(() => expect(getHeaderConfig().modelValue.value).toBe('installed'))
expect(await screen.findByText('plugin:市场安装插件')).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()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 市场安装插件 安装成功!'))
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()
})
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 () => {
let installRequests = 0
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 () => {
const installGate = createDeferred<void>()
const installStarted = createDeferred<void>()
let installed = false
const target = createPlugin({ id: 'PendingPlugin', plugin_name: '后台安装插件' })
await renderList({
@@ -1438,8 +1518,9 @@ describe('PluginCardListView search installation', () => {
await waitForRequestsToFinish()
server.use(
http.get(apiUrls.install('PendingPlugin'), async () => {
await installGate.promise
installed = true
installStarted.resolve()
await installGate.promise
return apiJson(null)
}),
)
@@ -1450,13 +1531,20 @@ describe('PluginCardListView search installation', () => {
expect(await screen.findByText('plugin:后台安装插件')).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('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()
installGate.resolve()
await waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 后台安装插件 安装成功!'))
await waitFor(() => expect(screen.getByLabelText('runtime-PendingPlugin')).toHaveTextContent('active'))
await waitFor(() => expect(screen.getByLabelText('installing-PendingPlugin')).toHaveTextContent('false'))
await waitForRequestsToFinish()
})