diff --git a/src/@core/components/ConfirmDialog.vue b/src/@core/components/ConfirmDialog.vue index 63d9f353..743f4660 100644 --- a/src/@core/components/ConfirmDialog.vue +++ b/src/@core/components/ConfirmDialog.vue @@ -4,6 +4,8 @@ import { computed } from 'vue' interface Props { modelValue: boolean type?: 'info' | 'warn' | 'error' + /** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */ + icon?: string title?: string content?: string confirmText?: string @@ -64,11 +66,11 @@ function handleCancel() {
- +

{{ title }}

-

{{ content }}

+

{{ content }}

@@ -84,3 +86,9 @@ function handleCancel() { + + diff --git a/src/api/types.ts b/src/api/types.ts index 3b11e775..5e816122 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -961,6 +961,10 @@ export interface Plugin { has_page?: boolean // 是否有新版本 has_update?: boolean + // 当前市场发现的最高在线更新候选 + update_candidate?: PluginUpdateCandidate | null + // 在线更新仓库绑定状态 + source_binding_status?: 'bound' | 'binding_required' | 'local_only' // 主系统版本是否兼容 system_version_compatible?: boolean // 主系统版本兼容提示 @@ -993,6 +997,20 @@ export interface Plugin { instance_mode?: 'virtual' } +/** 插件市场为已安装插件发现的当前最高在线更新候选。 */ +export interface PluginUpdateCandidate { + // 候选仓库是官方来源还是第三方来源 + source_type: 'official' | 'third_party' + // 候选仓库的规范来源键 + source_key: string + // 候选仓库的公开 GitHub 地址 + repo_url: string + // 候选仓库当前可安装版本 + version: string + // 候选仓库是否为插件当前已绑定仓库 + is_bound: boolean +} + /** 已绑定插件可用于自动更新的在线来源类型。 */ export type PluginTrustedSourceType = 'unknown' | 'official' | 'third_party' diff --git a/src/components/cards/PluginCard.vue b/src/components/cards/PluginCard.vue index 0b15c6c4..44059688 100644 --- a/src/components/cards/PluginCard.vue +++ b/src/components/cards/PluginCard.vue @@ -51,7 +51,27 @@ const emit = defineEmits(['remove', 'save', 'actionDone', 'rating']) const { t } = useI18n() const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0) -const hasCardStatus = computed(() => Boolean(props.plugin?.has_update) || hasCardRating.value) +const sourceBindingRequired = computed(() => props.plugin?.source_binding_status === 'binding_required') +const hasCardStatus = computed( + () => sourceBindingRequired.value || Boolean(props.plugin?.has_update) || hasCardRating.value, +) +const updateCandidate = computed(() => props.plugin?.update_candidate) +const hasAlternativeUpdate = computed( + () => Boolean(props.plugin?.has_update && updateCandidate.value && !updateCandidate.value.is_bound), +) +const updateSourceName = computed(() => { + const candidate = updateCandidate.value + if (!candidate) return '' + return candidate.source_key.startsWith('github:') ? candidate.source_key.slice('github:'.length) : candidate.source_key +}) +const updateBadgeTitle = computed(() => { + const candidate = updateCandidate.value + if (!candidate) return t('plugin.hasUpdate') + return t(candidate.is_bound ? 'plugin.boundUpdateAvailable' : 'plugin.alternativeUpdateAvailable', { + source: updateSourceName.value, + version: candidate.version, + }) +}) const runtimeStatus = computed(() => props.plugin?.runtime_status) const runtimePending = computed( () => props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || ''), @@ -401,7 +421,7 @@ async function fetchInstalledPluginDetail() { } /** 使用插件市场详情弹窗展示已安装插件信息和评分入口。 */ -async function showPluginAbout() { +async function showPluginAbout(initialSourceSelectionOpen = false) { const pluginDetail = await fetchInstalledPluginDetail() if (!pluginDetail) return @@ -411,6 +431,7 @@ async function showPluginAbout() { { plugin: pluginDetail, count: props.count, + initialSourceSelectionOpen, }, { install: () => { @@ -424,6 +445,15 @@ async function showPluginAbout() { ) } +/** 更新来自其他仓库时先展开来源选择,否则进入绑定仓库的更新说明。 */ +function handleUpdateAction() { + if (hasAlternativeUpdate.value) { + void showPluginAbout(true) + return + } + showUpdateHistory(true) +} + // 访问插件项目主页 async function visitPluginPage() { const popup = window.open('about:blank', '_blank') @@ -537,7 +567,7 @@ const dropdownItems = ref([ show: true, props: { prependIcon: 'mdi-information-outline', - click: showPluginAbout, + click: () => showPluginAbout(), }, }, { @@ -569,13 +599,13 @@ const dropdownItems = ref([ }, }, { - title: t('plugin.update'), + title: hasAlternativeUpdate.value ? t('plugin.viewUpdateSources') : t('plugin.update'), value: 3, show: props.plugin?.has_update, props: { prependIcon: 'mdi-arrow-up-circle-outline', color: 'success', - click: () => showUpdateHistory(true), + click: handleUpdateAction, }, }, { @@ -631,10 +661,15 @@ const dropdownItems = ref([ // 监听插件状态变化 watch( - () => props.plugin?.has_update, - newHasUpdate => { + () => [props.plugin?.has_update, props.plugin?.update_candidate?.is_bound] as const, + ([newHasUpdate]) => { const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3) - if (updateItemIndex !== -1) dropdownItems.value[updateItemIndex].show = newHasUpdate + if (updateItemIndex !== -1) { + dropdownItems.value[updateItemIndex].show = newHasUpdate + dropdownItems.value[updateItemIndex].title = hasAlternativeUpdate.value + ? t('plugin.viewUpdateSources') + : t('plugin.update') + } const updateHistoryItemIndex = dropdownItems.value.findIndex(item => item.value === 9) if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate @@ -780,12 +815,21 @@ watch(
+ + {{ t('plugin.sourceBindingRequired') }} + {{ t('plugin.sourceBindingRequiredHint') }} +
+
+ {{ updateBadgeTitle }}
{ expect(mocks.dialogCloses[1]).toHaveBeenCalled() }) + it('shows the repository type and keeps bound updates on the version history flow', async () => { + const updatablePlugin: Plugin = { + ...plugin, + has_update: true, + update_candidate: { + source_type: 'third_party', + source_key: 'github:example/plugins', + repo_url: 'https://github.com/example/plugins', + version: '2.0.0', + is_bound: true, + }, + } + const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } }) + + await fireEvent.mouseEnter(screen.getByLabelText('有更新')) + expect(await screen.findByText('example/plugins 有可直接安装的新版本 v2.0.0')).toBeInTheDocument() + await fireEvent.click(container.querySelector('.v-card .v-btn')!) + await fireEvent.click(await screen.findByText('更新')) + + expect(mocks.openSharedDialog).toHaveBeenCalledOnce() + expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/history/DemoPlugin', expect.anything()) + }) + + it('opens repository selection for an update published by another repository', async () => { + const updatablePlugin: Plugin = { + ...plugin, + has_update: true, + update_candidate: { + source_type: 'official', + source_key: 'github:jxxghp/moviepilot-plugins', + repo_url: 'https://github.com/jxxghp/MoviePilot-Plugins', + version: '2.0.0', + is_bound: false, + }, + } + mocks.apiGet.mockResolvedValue(updatablePlugin) + const { container } = await renderWithProviders(PluginCard, { props: { plugin: updatablePlugin } }) + + await fireEvent.mouseEnter(screen.getByLabelText('有更新')) + expect( + await screen.findByText('jxxghp/moviepilot-plugins 有新版本 v2.0.0,需要确认更换仓库'), + ).toBeInTheDocument() + await fireEvent.click(container.querySelector('.v-card .v-btn')!) + await fireEvent.click(await screen.findByText('查看更新来源')) + await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce()) + + expect(mocks.apiGet).toHaveBeenCalledWith('plugin/history/DemoPlugin', { + params: { force: false }, + }) + expect(mocks.openSharedDialog.mock.calls[0][1]).toMatchObject({ + initialSourceSelectionOpen: true, + plugin: expect.objectContaining({ id: 'DemoPlugin' }), + }) + expect(mocks.apiGet).not.toHaveBeenCalledWith('plugin/install/DemoPlugin', expect.anything()) + }) + + it('prioritizes repository confirmation over the update marker', async () => { + await renderWithProviders(PluginCard, { + props: { + plugin: { + ...plugin, + has_update: true, + source_binding_status: 'binding_required', + }, + }, + }) + + expect(screen.getByText('需确认仓库')).toBeInTheDocument() + expect(screen.queryByLabelText('有更新')).not.toBeInTheDocument() + await fireEvent.mouseEnter(screen.getByText('需确认仓库')) + expect(await screen.findByText('该插件尚未绑定仓库,请在「关于」中确认')).toBeInTheDocument() + }) + it('blocks an incompatible latest update without sending a request', async () => { const updatablePlugin = { ...plugin, diff --git a/src/components/dialog/PluginMarketDetailDialog.vue b/src/components/dialog/PluginMarketDetailDialog.vue index b797a624..c7e41ffa 100644 --- a/src/components/dialog/PluginMarketDetailDialog.vue +++ b/src/components/dialog/PluginMarketDetailDialog.vue @@ -42,6 +42,10 @@ const props = defineProps({ >, default: undefined, }, + initialSourceSelectionOpen: { + type: Boolean, + default: false, + }, }) // 定义触发的自定义事件 @@ -72,7 +76,7 @@ const sourceLoading = ref(false) const sourceError = ref('') const selectedInstallSourceKey = ref('') const selectedChangeSourceKey = ref('') -const showSourceChoices = ref(false) +const showSourceChoices = ref(props.initialSourceSelectionOpen) const sourceChanging = ref(false) // 图片是否加载失败 @@ -87,7 +91,16 @@ const onlineSourceCandidates = computed(() => // 官方仓库是默认可信来源,在所有选源入口中始终置顶。 .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(() => { + if (isInstalled.value) return false + if (sourceOptions.value?.selection_status === 'conflict') return true + return ( + sourceOptions.value?.selection_status === 'selected' && + onlineSourceCandidates.value.length === 1 && + onlineSourceCandidates.value[0].source_type === 'third_party' + ) +}) +const sourceHasConflict = computed(() => sourceOptions.value?.selection_status === 'conflict') const selectedInstallSource = computed(() => onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value), ) @@ -178,8 +191,13 @@ async function loadPluginSourceOptions(force = false) { ) if (!installSelectionStillExists) { const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official') + const selectedCandidate = installCandidates.length === 1 ? installCandidates[0] : undefined selectedInstallSourceKey.value = - !isInstalled.value && options.selection_status === 'conflict' ? officialCandidate?.source_key || '' : '' + !isInstalled.value && options.selection_status === 'conflict' + ? officialCandidate?.source_key || '' + : !isInstalled.value && selectedCandidate?.source_type === 'third_party' + ? selectedCandidate.source_key + : '' } const changeSelectionStillExists = sourceActionCandidates.value.some( @@ -202,15 +220,22 @@ async function confirmSourceTransition() { const bindingSource = !hasTrustedOnlineSource.value if (!props.plugin?.id || !target?.repo_url || (!bindingSource && !identity)) return - const confirmed = await createConfirm({ - type: 'warn', - title: t(bindingSource ? 'plugin.confirmSourceBindTitle' : 'plugin.confirmSourceChangeTitle'), - content: t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', { + const confirmationContent = [ + t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', { name: props.plugin.plugin_name, current: trustedSourceLabel(), target: sourceCandidateLabel(target), }), - confirmText: t(bindingSource ? 'plugin.bindSource' : 'plugin.changeSource'), + target.source_type === 'third_party' ? t('plugin.thirdPartySourceRisk') : '', + ] + .filter(Boolean) + .join('\n\n') + const confirmed = await createConfirm({ + type: 'warn', + icon: bindingSource ? 'mdi-shield-check-outline' : 'mdi-source-branch', + title: t(bindingSource ? 'plugin.confirmSourceBindTitle' : 'plugin.confirmSourceChangeTitle'), + content: confirmationContent, + confirmText: t(bindingSource ? 'plugin.confirmSourceBindAction' : 'plugin.confirmSourceChangeAction'), }) if (!confirmed) return @@ -322,6 +347,23 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) { const selectedRepoUrl = explicitSource?.repo_url || repoUrl + if (explicitSource?.source_type === 'third_party') { + const confirmed = await createConfirm({ + type: 'warn', + icon: 'mdi-shield-alert-outline', + title: t('plugin.confirmThirdPartyInstallTitle'), + content: [ + t('plugin.confirmThirdPartyInstall', { + name: props.plugin?.plugin_name, + target: sourceCandidateLabel(explicitSource), + }), + t('plugin.thirdPartySourceRisk'), + ].join('\n\n'), + confirmText: t('plugin.confirmThirdPartyInstallAction'), + }) + if (!confirmed) return + } + if (props.installHandler) { versionHistoryDialogController?.close() versionHistoryDialogController = null @@ -502,20 +544,23 @@ onUnmounted(() => {
- + class="plugin-market-detail__compatibility" + role="alert" + > + + {{ props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion') }} +

{{ t('plugin.source') }} + + {{ t(sourceHasConflict ? 'plugin.sourceSelectionRequired' : 'plugin.sourceConfirmationRequired') }} +

{{ @@ -523,20 +568,27 @@ onUnmounted(() => { ? t('plugin.sourceBindingHint') : isInstalled ? t('plugin.sourceInstalledHint') - : t('plugin.sourceConflictHint') + : sourceHasConflict + ? t('plugin.sourceConflictHint') + : t('plugin.sourceThirdPartyHint') }}

- + -
- - {{ t(hasTrustedOnlineSource ? 'plugin.changeSource' : 'plugin.bindSource') }} - - + + +
+ + {{ t('common.cancel') }} + + + {{ + t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.confirmSourceBindAction') + }} + +
@@ -758,6 +813,9 @@ onUnmounted(() => { } .plugin-market-detail-source__title { + display: inline-flex; + align-items: center; + gap: 0.375rem; font-size: 0.9375rem; font-weight: 600; line-height: 1.4; @@ -770,6 +828,13 @@ onUnmounted(() => { line-height: 1.45; } +.plugin-market-detail-source__message--error { + margin: 0 0 0.75rem; + color: rgb(var(--v-theme-error)); + font-size: 0.75rem; + line-height: 1.45; +} + .plugin-market-detail-source__identity { display: grid; gap: 0.5rem; @@ -797,11 +862,29 @@ onUnmounted(() => { text-align: end; } +.plugin-market-detail-source__identity-content { + display: grid; + grid-template-columns: minmax(0, 1fr) 1.75rem; + align-items: center; + gap: 0.375rem; +} + +.plugin-market-detail-source__identity-content--no-action { + display: flex; + justify-content: flex-end; +} + +.plugin-market-detail-source__identity-action { + inline-size: 1.75rem; + block-size: 1.75rem; +} + .plugin-market-detail-source__identity-value, .plugin-market-detail-source__choice-title { display: inline-flex; min-width: 0; align-items: center; + flex-wrap: wrap; justify-content: flex-end; gap: 0.375rem; } @@ -922,8 +1005,22 @@ onUnmounted(() => { text-decoration: underline; } -.plugin-market-detail__warning { - margin-block-start: 1rem; +.plugin-market-detail__compatibility { + display: flex; + max-inline-size: 24rem; + align-items: flex-start; + justify-content: center; + gap: 0.375rem; + margin: 1rem auto 0; + color: rgb(var(--v-theme-warning)); + font-size: 0.8125rem; + line-height: 1.45; + text-align: center; +} + +.plugin-market-detail__compatibility .v-icon { + flex: 0 0 auto; + margin-block-start: 0.0625rem; } .plugin-market-detail-actions { diff --git a/src/components/dialog/PluginVersionHistoryDialog.vue b/src/components/dialog/PluginVersionHistoryDialog.vue index cab4a2bc..80ecab8f 100644 --- a/src/components/dialog/PluginVersionHistoryDialog.vue +++ b/src/components/dialog/PluginVersionHistoryDialog.vue @@ -359,14 +359,14 @@ watch(