mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47: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 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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -3794,6 +3794,7 @@ export default {
|
||||
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
|
||||
local: '本地',
|
||||
source: '插件来源',
|
||||
sourceOfficial: '官方',
|
||||
sourceUnknown: '未知来源',
|
||||
sourceUnbound: '尚未绑定',
|
||||
sourceLoadFailed: '无法读取插件来源,请稍后重试',
|
||||
|
||||
@@ -3793,6 +3793,7 @@ export default {
|
||||
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
|
||||
local: '本地',
|
||||
source: '插件來源',
|
||||
sourceOfficial: '官方',
|
||||
sourceUnknown: '未知來源',
|
||||
sourceUnbound: '尚未綁定',
|
||||
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user