fix(plugin): 明确插件仓库绑定与换仓确认 (#724)

This commit is contained in:
InfinityPacer
2026-08-26 21:35:50 +08:00
committed by GitHub
parent 4be29cc02f
commit c840286a8d
15 changed files with 589 additions and 211 deletions
+10 -2
View File
@@ -4,6 +4,8 @@ import { computed } from 'vue'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
type?: 'info' | 'warn' | 'error' type?: 'info' | 'warn' | 'error'
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
icon?: string
title?: string title?: string
content?: string content?: string
confirmText?: string confirmText?: string
@@ -64,11 +66,11 @@ function handleCancel() {
<VCardItem> <VCardItem>
<div class="d-flex align-center justify-start mt-3"> <div class="d-flex align-center justify-start mt-3">
<VAvatar :color="currentType.color" variant="text" size="x-large"> <VAvatar :color="currentType.color" variant="text" size="x-large">
<VIcon size="x-large" :icon="currentType.icon" /> <VIcon size="x-large" :icon="icon || currentType.icon" />
</VAvatar> </VAvatar>
<div class="mx-3"> <div class="mx-3">
<p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p> <p class="font-weight-bold text-xl text-high-emphasis">{{ title }}</p>
<p>{{ content }}</p> <p class="app-confirm-dialog-content">{{ content }}</p>
</div> </div>
</div> </div>
</VCardItem> </VCardItem>
@@ -84,3 +86,9 @@ function handleCancel() {
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style scoped>
.app-confirm-dialog-content {
white-space: pre-line;
}
</style>
+18
View File
@@ -961,6 +961,10 @@ export interface Plugin {
has_page?: boolean has_page?: boolean
// 是否有新版本 // 是否有新版本
has_update?: boolean has_update?: boolean
// 当前市场发现的最高在线更新候选
update_candidate?: PluginUpdateCandidate | null
// 在线更新仓库绑定状态
source_binding_status?: 'bound' | 'binding_required' | 'local_only'
// 主系统版本是否兼容 // 主系统版本是否兼容
system_version_compatible?: boolean system_version_compatible?: boolean
// 主系统版本兼容提示 // 主系统版本兼容提示
@@ -993,6 +997,20 @@ export interface Plugin {
instance_mode?: 'virtual' 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' export type PluginTrustedSourceType = 'unknown' | 'official' | 'third_party'
+66 -10
View File
@@ -51,7 +51,27 @@ const emit = defineEmits(['remove', 'save', 'actionDone', 'rating'])
const { t } = useI18n() const { t } = useI18n()
const hasCardRating = computed(() => (props.plugin?.rating_count || 0) > 0) 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 runtimeStatus = computed(() => props.plugin?.runtime_status)
const runtimePending = computed( const runtimePending = computed(
() => props.runtimeSettling && ['source_missing', 'dependency_pending', 'ready'].includes(runtimeStatus.value || ''), () => 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() const pluginDetail = await fetchInstalledPluginDetail()
if (!pluginDetail) return if (!pluginDetail) return
@@ -411,6 +431,7 @@ async function showPluginAbout() {
{ {
plugin: pluginDetail, plugin: pluginDetail,
count: props.count, count: props.count,
initialSourceSelectionOpen,
}, },
{ {
install: () => { install: () => {
@@ -424,6 +445,15 @@ async function showPluginAbout() {
) )
} }
/** 更新来自其他仓库时先展开来源选择,否则进入绑定仓库的更新说明。 */
function handleUpdateAction() {
if (hasAlternativeUpdate.value) {
void showPluginAbout(true)
return
}
showUpdateHistory(true)
}
// 访问插件项目主页 // 访问插件项目主页
async function visitPluginPage() { async function visitPluginPage() {
const popup = window.open('about:blank', '_blank') const popup = window.open('about:blank', '_blank')
@@ -537,7 +567,7 @@ const dropdownItems = ref([
show: true, show: true,
props: { props: {
prependIcon: 'mdi-information-outline', 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, value: 3,
show: props.plugin?.has_update, show: props.plugin?.has_update,
props: { props: {
prependIcon: 'mdi-arrow-up-circle-outline', prependIcon: 'mdi-arrow-up-circle-outline',
color: 'success', color: 'success',
click: () => showUpdateHistory(true), click: handleUpdateAction,
}, },
}, },
{ {
@@ -631,10 +661,15 @@ const dropdownItems = ref([
// 监听插件状态变化 // 监听插件状态变化
watch( watch(
() => props.plugin?.has_update, () => [props.plugin?.has_update, props.plugin?.update_candidate?.is_bound] as const,
newHasUpdate => { ([newHasUpdate]) => {
const updateItemIndex = dropdownItems.value.findIndex(item => item.value === 3) 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) const updateHistoryItemIndex = dropdownItems.value.findIndex(item => item.value === 9)
if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate if (updateHistoryItemIndex !== -1) dropdownItems.value[updateHistoryItemIndex].show = !newHasUpdate
@@ -780,12 +815,21 @@ watch(
</div> </div>
</VCardText> </VCardText>
<div <div
v-if="props.plugin?.has_update" v-if="sourceBindingRequired"
class="plugin-card__status plugin-card__status--binding"
:aria-label="t('plugin.sourceBindingRequired')"
>
<VIcon icon="mdi-shield-alert-outline" size="12" />
{{ t('plugin.sourceBindingRequired') }}
<VTooltip activator="parent" location="top">{{ t('plugin.sourceBindingRequiredHint') }}</VTooltip>
</div>
<div
v-else-if="props.plugin?.has_update"
class="plugin-card__status plugin-card__status--update" class="plugin-card__status plugin-card__status--update"
:aria-label="t('plugin.hasUpdate')" :aria-label="t('plugin.hasUpdate')"
:title="t('plugin.hasUpdate')"
> >
<VIcon icon="mdi-new-box" class="text-white" size="20" /> <VIcon icon="mdi-new-box" class="text-white" size="20" />
<VTooltip activator="parent" location="top">{{ updateBadgeTitle }}</VTooltip>
</div> </div>
<div <div
v-else-if="hasCardRating" v-else-if="hasCardRating"
@@ -830,6 +874,18 @@ watch(
font-weight: 600; font-weight: 600;
} }
.plugin-card__status--binding {
gap: 0.1875rem;
padding: 0.1875rem 0.375rem;
border: 1px solid rgba(var(--v-theme-warning), 45%);
border-radius: 4px;
background: rgba(var(--v-theme-warning), 16%);
color: rgb(var(--v-theme-warning));
font-size: 0.6875rem;
font-weight: 600;
text-shadow: none;
}
.plugin-card--runtime-pending { .plugin-card--runtime-pending {
cursor: progress; cursor: progress;
} }
@@ -199,6 +199,79 @@ describe('PluginCard lifecycle actions', () => {
expect(mocks.dialogCloses[1]).toHaveBeenCalled() 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<HTMLButtonElement>('.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<HTMLButtonElement>('.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 () => { it('blocks an incompatible latest update without sending a request', async () => {
const updatablePlugin = { const updatablePlugin = {
...plugin, ...plugin,
@@ -42,6 +42,10 @@ const props = defineProps({
>, >,
default: undefined, default: undefined,
}, },
initialSourceSelectionOpen: {
type: Boolean,
default: false,
},
}) })
// 定义触发的自定义事件 // 定义触发的自定义事件
@@ -72,7 +76,7 @@ const sourceLoading = ref(false)
const sourceError = ref('') const sourceError = ref('')
const selectedInstallSourceKey = ref('') const selectedInstallSourceKey = ref('')
const selectedChangeSourceKey = ref('') const selectedChangeSourceKey = ref('')
const showSourceChoices = ref(false) const showSourceChoices = ref(props.initialSourceSelectionOpen)
const sourceChanging = ref(false) const sourceChanging = ref(false)
// 图片是否加载失败 // 图片是否加载失败
@@ -87,7 +91,16 @@ const onlineSourceCandidates = computed(() =>
// 官方仓库是默认可信来源,在所有选源入口中始终置顶。 // 官方仓库是默认可信来源,在所有选源入口中始终置顶。
.sort((left, right) => Number(right.source_type === 'official') - Number(left.source_type === 'official')), .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(() => const selectedInstallSource = computed(() =>
onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value), onlineSourceCandidates.value.find(candidate => candidate.source_key === selectedInstallSourceKey.value),
) )
@@ -178,8 +191,13 @@ async function loadPluginSourceOptions(force = false) {
) )
if (!installSelectionStillExists) { if (!installSelectionStillExists) {
const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official') const officialCandidate = installCandidates.find(candidate => candidate.source_type === 'official')
const selectedCandidate = installCandidates.length === 1 ? installCandidates[0] : undefined
selectedInstallSourceKey.value = 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( const changeSelectionStillExists = sourceActionCandidates.value.some(
@@ -202,15 +220,22 @@ async function confirmSourceTransition() {
const bindingSource = !hasTrustedOnlineSource.value const bindingSource = !hasTrustedOnlineSource.value
if (!props.plugin?.id || !target?.repo_url || (!bindingSource && !identity)) return if (!props.plugin?.id || !target?.repo_url || (!bindingSource && !identity)) return
const confirmed = await createConfirm({ const confirmationContent = [
type: 'warn', t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', {
title: t(bindingSource ? 'plugin.confirmSourceBindTitle' : 'plugin.confirmSourceChangeTitle'),
content: t(bindingSource ? 'plugin.confirmSourceBind' : 'plugin.confirmSourceChange', {
name: props.plugin.plugin_name, name: props.plugin.plugin_name,
current: trustedSourceLabel(), current: trustedSourceLabel(),
target: sourceCandidateLabel(target), 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 if (!confirmed) return
@@ -322,6 +347,23 @@ async function installPlugin(releaseVersion?: string, repoUrl?: string) {
const selectedRepoUrl = explicitSource?.repo_url || repoUrl 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) { if (props.installHandler) {
versionHistoryDialogController?.close() versionHistoryDialogController?.close()
versionHistoryDialogController = null versionHistoryDialogController = null
@@ -502,20 +544,23 @@ onUnmounted(() => {
</div> </div>
</dl> </dl>
<VAlert <p
v-if="props.plugin?.system_version_compatible === false" v-if="props.plugin?.system_version_compatible === false"
type="warning" class="plugin-market-detail__compatibility"
variant="tonal" role="alert"
density="compact" >
class="plugin-market-detail__warning" <VIcon icon="mdi-lock-outline" size="16" />
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')" <span>{{ props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}</span>
/> </p>
<section v-if="sourceSectionVisible" class="plugin-market-detail-source" aria-labelledby="plugin-source-title"> <section v-if="sourceSectionVisible" class="plugin-market-detail-source" aria-labelledby="plugin-source-title">
<div class="plugin-market-detail-source__heading"> <div class="plugin-market-detail-source__heading">
<div> <div>
<h3 id="plugin-source-title" class="plugin-market-detail-source__title"> <h3 id="plugin-source-title" class="plugin-market-detail-source__title">
{{ t('plugin.source') }} {{ t('plugin.source') }}
<VChip v-if="sourceNeedsSelection" size="x-small" color="warning" variant="tonal">
{{ t(sourceHasConflict ? 'plugin.sourceSelectionRequired' : 'plugin.sourceConfirmationRequired') }}
</VChip>
</h3> </h3>
<p class="plugin-market-detail-source__hint"> <p class="plugin-market-detail-source__hint">
{{ {{
@@ -523,20 +568,27 @@ onUnmounted(() => {
? t('plugin.sourceBindingHint') ? t('plugin.sourceBindingHint')
: isInstalled : isInstalled
? t('plugin.sourceInstalledHint') ? t('plugin.sourceInstalledHint')
: t('plugin.sourceConflictHint') : sourceHasConflict
? t('plugin.sourceConflictHint')
: t('plugin.sourceThirdPartyHint')
}} }}
</p> </p>
</div> </div>
<VProgressCircular v-if="sourceLoading" indeterminate size="20" width="2" /> <VProgressCircular v-if="sourceLoading" indeterminate size="20" width="2" />
</div> </div>
<VAlert v-if="sourceError" type="warning" variant="tonal" density="compact" :text="sourceError" /> <p v-if="sourceError" class="plugin-market-detail-source__message--error" role="alert">
{{ sourceError }}
</p>
<template v-if="sourceOptions"> <template v-if="sourceOptions">
<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> <dd
class="plugin-market-detail-source__identity-content"
:class="{ 'plugin-market-detail-source__identity-content--no-action': !sourceActionCandidates.length || showSourceChoices }"
>
<span class="plugin-market-detail-source__identity-value"> <span class="plugin-market-detail-source__identity-value">
<VChip <VChip
v-if="sourceOptions.identity.trusted_source_type === 'official'" v-if="sourceOptions.identity.trusted_source_type === 'official'"
@@ -549,6 +601,20 @@ onUnmounted(() => {
</VChip> </VChip>
{{ trustedSourceLabel() }} {{ trustedSourceLabel() }}
</span> </span>
<VBtn
v-if="sourceActionCandidates.length > 0 && !showSourceChoices"
class="plugin-market-detail-source__identity-action"
icon
size="x-small"
variant="text"
:aria-label="t(hasTrustedOnlineSource ? 'plugin.changeSourceInline' : 'plugin.bindSourceInline')"
@click="showSourceChoices = true"
>
<VIcon :icon="hasTrustedOnlineSource ? 'mdi-source-branch' : 'mdi-shield-plus-outline'" size="18" />
<VTooltip activator="parent" location="top">
{{ t(hasTrustedOnlineSource ? 'plugin.changeSourceInline' : 'plugin.bindSourceInline') }}
</VTooltip>
</VBtn>
</dd> </dd>
</div> </div>
<div v-if="sourceOptions.identity.payload_source_type === 'local'"> <div v-if="sourceOptions.identity.payload_source_type === 'local'">
@@ -557,14 +623,9 @@ onUnmounted(() => {
</div> </div>
</dl> </dl>
<VAlert <p v-if="sourceUnavailable" class="plugin-market-detail-source__message--error" role="alert">
v-if="sourceNeedsSelection || sourceNeedsInitialBinding || sourceUnavailable" {{ sourceOptions.selection_reason }}
:type="sourceNeedsSelection || sourceNeedsInitialBinding ? 'warning' : 'error'" </p>
variant="tonal"
density="compact"
class="mb-3"
:text="sourceOptions.selection_reason"
/>
<VRadioGroup <VRadioGroup
v-if="sourceNeedsSelection" v-if="sourceNeedsSelection"
@@ -593,67 +654,61 @@ onUnmounted(() => {
<strong>{{ sourceCandidateLabel(candidate) }}</strong> <strong>{{ sourceCandidateLabel(candidate) }}</strong>
</span> </span>
<span class="plugin-market-detail-source__choice-meta" <span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span >v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}</span
> >
</span> </span>
</template> </template>
</VRadio> </VRadio>
</VRadioGroup> </VRadioGroup>
<div v-if="isInstalled && sourceActionCandidates.length > 0" class="plugin-market-detail-source__change"> <div
<VBtn v-if="isInstalled && sourceActionCandidates.length > 0 && showSourceChoices"
v-if="!showSourceChoices" class="plugin-market-detail-source__change"
size="small" >
variant="text" <VRadioGroup v-model="selectedChangeSourceKey" hide-details>
prepend-icon="mdi-source-branch" <VRadio
@click="showSourceChoices = true" v-for="candidate in sourceActionCandidates"
> :key="candidate.source_key"
{{ t(hasTrustedOnlineSource ? 'plugin.changeSource' : 'plugin.bindSource') }} :value="candidate.source_key"
</VBtn> >
<template v-else> <template #label>
<VRadioGroup v-model="selectedChangeSourceKey" hide-details> <span class="plugin-market-detail-source__choice-label">
<VRadio <span class="plugin-market-detail-source__choice-title">
v-for="candidate in sourceActionCandidates" <VChip
:key="candidate.source_key" v-if="candidate.source_type === 'official'"
:value="candidate.source_key" size="x-small"
> color="primary"
<template #label> variant="tonal"
<span class="plugin-market-detail-source__choice-label"> prepend-icon="mdi-shield-check"
<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
> >
{{ t('plugin.sourceOfficial') }}
</VChip>
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
</span> </span>
</template> <span class="plugin-market-detail-source__choice-meta"
</VRadio> >v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}</span
</VRadioGroup> >
<div class="plugin-market-detail-source__change-actions"> </span>
<VBtn size="small" variant="text" @click="showSourceChoices = false"> </template>
{{ t('common.cancel') }} </VRadio>
</VBtn> </VRadioGroup>
<VBtn <div class="plugin-market-detail-source__change-actions">
size="small" <VBtn size="small" variant="text" @click="showSourceChoices = false">
color="warning" {{ t('common.cancel') }}
:loading="sourceChanging" </VBtn>
:disabled="!selectedChangeSource" <VBtn
@click="confirmSourceTransition" size="small"
> color="primary"
{{ t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.bindSource') }} variant="text"
</VBtn> :loading="sourceChanging"
</div> :disabled="!selectedChangeSource"
</template> @click="confirmSourceTransition"
>
{{
t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.confirmSourceBindAction')
}}
</VBtn>
</div>
</div> </div>
</template> </template>
</section> </section>
@@ -758,6 +813,9 @@ onUnmounted(() => {
} }
.plugin-market-detail-source__title { .plugin-market-detail-source__title {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 600; font-weight: 600;
line-height: 1.4; line-height: 1.4;
@@ -770,6 +828,13 @@ onUnmounted(() => {
line-height: 1.45; 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 { .plugin-market-detail-source__identity {
display: grid; display: grid;
gap: 0.5rem; gap: 0.5rem;
@@ -797,11 +862,29 @@ onUnmounted(() => {
text-align: end; 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__identity-value,
.plugin-market-detail-source__choice-title { .plugin-market-detail-source__choice-title {
display: inline-flex; display: inline-flex;
min-width: 0; min-width: 0;
align-items: center; align-items: center;
flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
gap: 0.375rem; gap: 0.375rem;
} }
@@ -922,8 +1005,22 @@ onUnmounted(() => {
text-decoration: underline; text-decoration: underline;
} }
.plugin-market-detail__warning { .plugin-market-detail__compatibility {
margin-block-start: 1rem; 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 { .plugin-market-detail-actions {
@@ -359,14 +359,14 @@ watch(
<template v-if="shouldShowUpdatePanel"> <template v-if="shouldShowUpdatePanel">
<VDivider /> <VDivider />
<VCardItem> <VCardItem>
<VAlert <p
v-if="resolvedPlugin?.system_version_compatible === false" v-if="resolvedPlugin?.system_version_compatible === false"
type="warning" class="plugin-version-history-dialog__compatibility"
variant="tonal" role="alert"
density="compact" >
class="mb-3" <VIcon icon="mdi-lock-outline" size="16" />
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')" <span>{{ resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}</span>
/> </p>
<VBtn <VBtn
@click="handleUpdate()" @click="handleUpdate()"
block block
@@ -395,6 +395,23 @@ watch(
white-space: nowrap; white-space: nowrap;
} }
.plugin-version-history-dialog__compatibility {
display: flex;
align-items: flex-start;
justify-content: center;
gap: 0.375rem;
margin: 0 0 0.75rem;
color: rgb(var(--v-theme-warning));
font-size: 0.8125rem;
line-height: 1.45;
text-align: center;
}
.plugin-version-history-dialog__compatibility .v-icon {
flex: 0 0 auto;
margin-block-start: 0.0625rem;
}
.plugin-release-meta { .plugin-release-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -127,6 +127,10 @@ describe('PluginMarketDetailDialog', () => {
await renderDialog({ ...basePlugin, installed: false }) await renderDialog({ ...basePlugin, installed: false })
expect(await screen.findByText('安装到本地')).toBeInTheDocument() expect(await screen.findByText('安装到本地')).toBeInTheDocument()
expect(screen.getByText('该插件来自第三方仓库,请确认后安装。')).toBeInTheDocument()
expect(screen.getByText('需确认')).toBeInTheDocument()
expect(screen.queryByText('需选择')).not.toBeInTheDocument()
expect(screen.queryByText('检测到多个同名插件,请选择仓库。')).not.toBeInTheDocument()
expect(screen.queryByText('提交评分')).not.toBeInTheDocument() expect(screen.queryByText('提交评分')).not.toBeInTheDocument()
expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument() expect(screen.getByLabelText('4.3 / 5')).toBeInTheDocument()
}) })
@@ -139,7 +143,7 @@ describe('PluginMarketDetailDialog', () => {
...defaultSourceOptions, ...defaultSourceOptions,
identity: null, identity: null,
selection_status: 'conflict', selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择', selection_reason: '插件存在多个在线来源,请确认来源后安装。',
candidates: [ candidates: [
defaultSourceOptions.candidates[0], defaultSourceOptions.candidates[0],
{ {
@@ -156,7 +160,9 @@ describe('PluginMarketDetailDialog', () => {
}) })
const { emitted } = await renderDialog({ ...basePlugin, installed: false }) const { emitted } = await renderDialog({ ...basePlugin, installed: false })
expect(await screen.findByText('未安装插件存在多个在线来源,不能静默选择')).toBeInTheDocument() expect(await screen.findByText('检测到多个同名插件,请选择仓库。')).toBeInTheDocument()
expect(screen.getByText('需选择')).toBeInTheDocument()
expect(screen.queryByText('该插件存在多个在线来源,请确认来源后安装。')).not.toBeInTheDocument()
const installButton = screen.getByRole('button', { name: '安装到本地' }) const installButton = screen.getByRole('button', { name: '安装到本地' })
expect(installButton).toBeEnabled() expect(installButton).toBeEnabled()
@@ -207,20 +213,21 @@ describe('PluginMarketDetailDialog', () => {
}) })
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true }) const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
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() expect(screen.getByText('本地')).toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '更换来源' })) await fireEvent.click(screen.getByRole('button', { name: '更换' }))
await fireEvent.click(screen.getByText('example/plugins')) await fireEvent.click(screen.getByText('example/plugins'))
await fireEvent.click(screen.getByRole('button', { name: '确认换' })) await fireEvent.click(screen.getByRole('button', { name: '确认换' }))
expect(mocks.confirm).toHaveBeenCalledWith( expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
icon: 'mdi-source-branch',
content: expect.stringContaining('jxxghp/moviepilot-plugins'), content: expect.stringContaining('jxxghp/moviepilot-plugins'),
confirmText: '更换来源', confirmText: '确认更换',
}), }),
) )
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', { expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
@@ -237,7 +244,7 @@ describe('PluginMarketDetailDialog', () => {
return Promise.resolve({ return Promise.resolve({
...defaultSourceOptions, ...defaultSourceOptions,
selection_status: 'unavailable', selection_status: 'unavailable',
selection_reason: '当前来源身份没有可用候选', selection_reason: '已绑定仓库中暂无可用插件包',
identity: { identity: {
plugin_id: 'DemoPlugin', plugin_id: 'DemoPlugin',
trusted_source_type: 'official', trusted_source_type: 'official',
@@ -254,8 +261,8 @@ describe('PluginMarketDetailDialog', () => {
}) })
await renderDialog({ ...basePlugin, installed: true, has_update: true }) await renderDialog({ ...basePlugin, installed: true, has_update: true })
expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument() expect(await screen.findByText('已绑定仓库中暂无可用插件包')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更换来源' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '更换' })).toBeInTheDocument()
const updateButton = screen.getByRole('button', { name: '更新' }) const updateButton = screen.getByRole('button', { name: '更新' })
expect(updateButton).toBeDisabled() expect(updateButton).toBeDisabled()
await fireEvent.click(updateButton) await fireEvent.click(updateButton)
@@ -295,16 +302,17 @@ describe('PluginMarketDetailDialog', () => {
}) })
const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true }) const { emitted } = await renderDialog({ ...basePlugin, installed: true, has_update: true })
expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument() expect(await screen.findByText('当前插件尚未绑定,请选择仓库。')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更新' })).toBeDisabled() expect(screen.getByRole('button', { name: '更新' })).toBeDisabled()
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' })) await fireEvent.click(screen.getByRole('button', { name: '绑定' }))
await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins')) await fireEvent.click(screen.getByText('jxxghp/moviepilot-plugins'))
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' })) await fireEvent.click(screen.getByRole('button', { name: '确认绑定' }))
expect(mocks.confirm).toHaveBeenCalledWith( expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
title: '确认绑定插件来源', icon: 'mdi-shield-check-outline',
confirmText: '绑定来源', title: '确认绑定仓库',
confirmText: '确认绑定',
}), }),
) )
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', { expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
@@ -349,7 +357,7 @@ describe('PluginMarketDetailDialog', () => {
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(screen.getByRole('button', { name: '绑定来源' })).toBeInTheDocument() expect(screen.getByRole('button', { name: '绑定' })).toBeInTheDocument()
}) })
it('reloads source evidence after a stale revision failure without retrying the change', async () => { it('reloads source evidence after a stale revision failure without retrying the change', async () => {
@@ -390,9 +398,9 @@ describe('PluginMarketDetailDialog', () => {
}) })
const { emitted } = await renderDialog({ ...basePlugin, installed: true }) const { emitted } = await renderDialog({ ...basePlugin, installed: true })
await fireEvent.click(await screen.findByRole('button', { name: '更换来源' })) await fireEvent.click(await screen.findByRole('button', { name: '更换' }))
await fireEvent.click(screen.getByText('example/plugins')) await fireEvent.click(screen.getByText('example/plugins'))
await fireEvent.click(screen.getByRole('button', { name: '确认换' })) await fireEvent.click(screen.getByRole('button', { name: '确认换' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化'))) await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化')))
expect(mocks.apiPost).toHaveBeenCalledTimes(1) expect(mocks.apiPost).toHaveBeenCalledTimes(1)
@@ -485,13 +493,19 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' })) await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
await waitFor(() => { await waitFor(() => {
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', { expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
params: { repo_url: 'https://github.com/example/plugins',
force: false, release_version: undefined,
release_version: undefined, force: false,
},
}) })
}) })
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: '确认安装',
confirmText: '安装',
content: expect.stringContaining('后续更新也将使用该仓库'),
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!') expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
expect(emitted().install).toHaveLength(1) expect(emitted().install).toHaveLength(1)
expect(emitted()['update:modelValue']).toContainEqual([false]) expect(emitted()['update:modelValue']).toContainEqual([false])
@@ -504,7 +518,11 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' })) await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
expect(installHandler).toHaveBeenCalledWith(undefined, undefined, defaultSourceOptions) expect(installHandler).toHaveBeenCalledWith(
undefined,
'https://github.com/example/plugins',
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])
@@ -565,11 +583,10 @@ describe('PluginMarketDetailDialog', () => {
await versionEvents.update('0.9.0', 'https://github.com/example/releases') await versionEvents.update('0.9.0', 'https://github.com/example/releases')
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') })) expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ content: expect.stringContaining('v0.9.0') }))
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', { expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
params: { repo_url: 'https://github.com/example/plugins',
force: true, release_version: '0.9.0',
release_version: '0.9.0', force: true,
},
}) })
expect(emitted().install).toHaveLength(1) expect(emitted().install).toHaveLength(1)
}) })
@@ -133,6 +133,32 @@ describe('PluginVersionHistoryDialog', () => {
]) ])
}) })
it('shows the version requirement and disables the latest update when the host is incompatible', async () => {
mocks.apiGet.mockImplementation((url: string) => {
if (url === 'plugin/history/DemoPlugin') {
return Promise.resolve({
...installedPlugin,
system_version_compatible: false,
system_version_message: '该插件要求 MoviePilot >=4.0.0',
history: { 'v1.0.0': '当前更新说明' },
})
}
if (url === 'plugin/source/DemoPlugin/options') return Promise.resolve(sourceOptions)
if (url === 'plugin/releases/DemoPlugin') return Promise.resolve(releases)
throw new Error(`Unexpected request: ${url}`)
})
await renderDialog({
modelValue: true,
plugin: installedPlugin,
showUpdateAction: true,
actionMode: 'update',
})
expect(await screen.findByRole('alert')).toHaveTextContent('该插件要求 MoviePilot >=4.0.0')
expect(screen.getByRole('button', { name: '更新到最新版本' })).toBeDisabled()
})
it('requires source binding before a legacy plugin can update from history', async () => { it('requires source binding before a legacy plugin can update from history', async () => {
const legacySourceOptions: PluginSourceOptions = { const legacySourceOptions: PluginSourceOptions = {
...sourceOptions, ...sourceOptions,
@@ -163,10 +189,10 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update', actionMode: 'update',
}) })
expect(await screen.findByRole('button', { name: '绑定来源' })).toBeInTheDocument() expect(await screen.findByRole('button', { name: '绑定仓库' })).toBeInTheDocument()
expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument() expect(await screen.findByText('当前插件尚未绑定,请选择仓库。')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument()
await fireEvent.click(screen.getByRole('button', { name: '绑定来源' })) await fireEvent.click(screen.getByRole('button', { name: '绑定仓库' }))
expect(emitted().sourceAction).toEqual([[]]) expect(emitted().sourceAction).toEqual([[]])
expect(emitted().update).toBeUndefined() expect(emitted().update).toBeUndefined()
@@ -249,7 +275,7 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update', actionMode: 'update',
}) })
const bindButton = await screen.findByRole('button', { name: '绑定来源' }) const bindButton = await screen.findByRole('button', { name: '绑定仓库' })
await fireEvent.click(bindButton) await fireEvent.click(bindButton)
expect(emitted().sourceAction).toEqual([[]]) expect(emitted().sourceAction).toEqual([[]])
@@ -299,7 +325,7 @@ describe('PluginVersionHistoryDialog', () => {
const unavailableSourceOptions: PluginSourceOptions = { const unavailableSourceOptions: PluginSourceOptions = {
...sourceOptions, ...sourceOptions,
selection_status: 'unavailable', selection_status: 'unavailable',
selection_reason: '当前来源身份没有可用候选', selection_reason: '已绑定仓库中暂无可用插件包',
candidates: [], candidates: [],
} }
mocks.apiGet.mockImplementation((url: string) => { mocks.apiGet.mockImplementation((url: string) => {
@@ -317,7 +343,7 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update', actionMode: 'update',
}) })
expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument() expect(await screen.findByText('已绑定仓库中暂无可用插件包')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '安装' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '更新到最新版本' })).not.toBeInTheDocument() expect(screen.queryByRole('button', { name: '更新到最新版本' })).not.toBeInTheDocument()
expect(emitted().update).toBeUndefined() expect(emitted().update).toBeUndefined()
+2
View File
@@ -7,6 +7,8 @@ import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
/** 主应用确认弹窗支持的配置项。 */ /** 主应用确认弹窗支持的配置项。 */
export interface ConfirmOptions { export interface ConfirmOptions {
type?: 'info' | 'warn' | 'error' type?: 'info' | 'warn' | 'error'
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
icon?: string
title?: string title?: string
content?: string content?: string
confirmText?: string confirmText?: string
+38 -23
View File
@@ -3841,6 +3841,9 @@ export default {
installed: 'Installed', installed: 'Installed',
notInstalled: 'Not Installed', notInstalled: 'Not Installed',
hasUpdate: 'Update Available', hasUpdate: 'Update Available',
boundUpdateAvailable: '{source} has an installable update v{version}',
alternativeUpdateAvailable: '{source} has update v{version}; changing repositories requires confirmation',
viewUpdateSources: 'View Update Sources',
configuring: 'Configuring', configuring: 'Configuring',
enable: 'Enable', enable: 'Enable',
disable: 'Disable', disable: 'Disable',
@@ -3856,33 +3859,45 @@ export default {
confirmInstallOldRelease: confirmInstallOldRelease:
'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: 'Source',
sourceOfficial: 'Official', sourceOfficial: 'Official',
sourceUnknown: 'Unknown source', sourceUnknown: 'Unknown',
sourceUnbound: 'Not bound', sourceUnbound: 'Not bound',
sourceLoadFailed: 'Unable to load plugin sources. Try again later.', sourceLoadFailed: 'Source information is temporarily unavailable. Try again later.',
sourceUnavailable: 'No plugin source is currently safe to install', sourceUnavailable: 'No repository is currently available',
sourceInstalledHint: 'Automatic updates only use the bound trusted repository.', sourceInstalledHint: 'Plugins are updated only from the bound repository.',
sourceConflictHint: 'Multiple plugins share this ID. Choose the repository you trust.', sourceBindingRequired: 'Repository confirmation required',
sourceBindingHint: 'This plugin has no automatic update source yet. Choose a repository you trust.', sourceBindingRequiredHint: 'This plugin has no bound repository; open About to confirm',
trustedUpdateSource: 'Automatic update source', sourceConflictHint: 'Multiple plugins share this ID. Choose a repository.',
currentPayloadSource: 'Current payload', sourceThirdPartyHint: 'This plugin comes from a third-party repository. Confirm before installing.',
selectSourceRequired: 'Choose a plugin source before installing', sourceSelectionRequired: 'Selection required',
bindSource: 'Bind Source', sourceConfirmationRequired: 'Confirmation required',
confirmSourceBindTitle: 'Confirm Plugin Source', sourceBindingHint: 'This plugin is not bound. Choose a repository.',
confirmSourceBind: trustedUpdateSource: 'Repository',
'Bind the automatic update source for {name} to {target}. The current version from that source will be installed immediately.', currentPayloadSource: 'Runtime source',
bindingSource: 'Binding the plugin source for {name}...', selectSourceRequired: 'Choose a repository before installing.',
sourceBindSuccess: 'The source for plugin {name} was bound', bindSource: 'Bind Repository',
sourceBindFailed: 'Failed to bind the source for plugin {name}: {message}', bindSourceInline: 'Bind',
changeSource: 'Change Source', confirmThirdPartyInstallTitle: 'Confirm Installation',
confirmSourceChangeTitle: 'Confirm Plugin Source Change', confirmThirdPartyInstall: 'Install “{name}” from {target}. Future updates will also use this repository.',
confirmThirdPartyInstallAction: 'Install',
confirmSourceBindTitle: 'Confirm Repository Binding',
confirmSourceBind: 'Bind “{name}” to {target} and install the current version from this repository.',
thirdPartySourceRisk:
'This is a third-party repository. Future updates will come from it. Confirm to continue.',
confirmSourceBindAction: 'Confirm Bind',
bindingSource: 'Installing {name} from the selected repository...',
sourceBindSuccess: 'The repository for {name} was bound',
sourceBindFailed: 'Failed to bind the repository for {name}: {message}',
changeSource: 'Change Repository',
changeSourceInline: 'Change',
confirmSourceChangeTitle: 'Confirm Repository Change',
confirmSourceChange: confirmSourceChange:
'Change the automatic update source for {name} from {current} to {target}. The current version from the target source will be installed immediately.', 'Change the repository for {name} from {current} to {target}. The current version from the target repository will be installed immediately.',
confirmSourceChangeAction: 'Confirm Change', confirmSourceChangeAction: 'Confirm Change',
changingSource: 'Changing the plugin source for {name}...', changingSource: 'Changing the repository for {name}...',
sourceChangeSuccess: 'The source for plugin {name} was changed', sourceChangeSuccess: 'The repository for {name} was changed',
sourceChangeFailed: 'Failed to change the source for plugin {name}: {message}', sourceChangeFailed: 'Failed to change the repository for {name}: {message}',
systemVersion: 'System Version', systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.', incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
installToLocal: 'Install to Local', installToLocal: 'Install to Local',
+39 -25
View File
@@ -3778,6 +3778,9 @@ export default {
installed: '已安装', installed: '已安装',
notInstalled: '未安装', notInstalled: '未安装',
hasUpdate: '有更新', hasUpdate: '有更新',
boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
viewUpdateSources: '查看更新来源',
configuring: '配置', configuring: '配置',
enable: '启用', enable: '启用',
disable: '禁用', disable: '禁用',
@@ -3793,32 +3796,43 @@ export default {
confirmInstallOldRelease: confirmInstallOldRelease:
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。', '是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地', local: '本地',
source: '插件来源', source: '来源',
sourceOfficial: '官方', sourceOfficial: '官方',
sourceUnknown: '未知来源', sourceUnknown: '未知',
sourceUnbound: '未绑定', sourceUnbound: '未绑定',
sourceLoadFailed: '无法读取插件来源,请稍后重试', sourceLoadFailed: '来源信息暂时无法读取,请稍后重试',
sourceUnavailable: '当前没有可安全安装的插件来源', sourceUnavailable: '当前没有可用仓库',
sourceInstalledHint: '自动更新只会使用已绑定的可信仓库。', sourceInstalledHint: '插件只会从已绑定仓库更新。',
sourceConflictHint: '检测到多个同名插件,请明确选择要信任的仓库', sourceBindingRequired: '需确认仓库',
sourceBindingHint: '当前插件尚未绑定自动更新来源,请选择可信仓库。', sourceBindingRequiredHint: '插件尚未绑定仓库,请在「关于」中确认',
trustedUpdateSource: '自动更新来源', sourceConflictHint: '检测到多个同名插件,请选择仓库。',
currentPayloadSource: '当前载荷', sourceThirdPartyHint: '该插件来自第三方仓库,请确认后安装。',
selectSourceRequired: '选择插件来源后再安装', sourceSelectionRequired: '选择',
bindSource: '绑定来源', sourceConfirmationRequired: '需确认',
confirmSourceBindTitle: '确认绑定插件来源', sourceBindingHint: '当前插件尚未绑定,请选择仓库。',
confirmSourceBind: '将插件 {name} 的自动更新来源绑定为 {target}。确认后会立即安装该来源的当前版本。', trustedUpdateSource: '仓库',
bindingSource: '正在绑定 {name} 的插件来源...', currentPayloadSource: '运行来源',
sourceBindSuccess: '插件 {name} 的来源已绑定', selectSourceRequired: '请选择仓库后再安装。',
sourceBindFailed: '插件 {name} 绑定来源失败:{message}', bindSource: '绑定仓库',
changeSource: '更换来源', bindSourceInline: '绑定',
confirmSourceChangeTitle: '确认更换插件来源', confirmThirdPartyInstallTitle: '确认安装',
confirmSourceChange: confirmThirdPartyInstall: '将从 {target} 安装「{name}」,后续更新也将使用该仓库。',
'将插件 {name} 的自动更新来源从 {current} 更换为 {target}。确认后会立即安装目标来源的当前版本。', confirmThirdPartyInstallAction: '安装',
confirmSourceChangeAction: '确认换源', confirmSourceBindTitle: '确认绑定仓库',
changingSource: '正在更换 {name} 的插件来源...', confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
sourceChangeSuccess: '插件 {name} 的来源已更换', thirdPartySourceRisk: '这是第三方仓库,后续更新将来自该仓库,请确认后继续。',
sourceChangeFailed: '插件 {name} 换源失败:{message}', confirmSourceBindAction: '确认绑定',
bindingSource: '正在从所选仓库安装 {name}...',
sourceBindSuccess: '插件 {name} 已绑定仓库',
sourceBindFailed: '插件 {name} 绑定仓库失败:{message}',
changeSource: '更换仓库',
changeSourceInline: '更换',
confirmSourceChangeTitle: '确认更换仓库',
confirmSourceChange: '将插件「{name}」的仓库从 {current} 更换为 {target},确认后会立即安装目标仓库的当前版本。',
confirmSourceChangeAction: '确认更换',
changingSource: '正在更换 {name} 的仓库...',
sourceChangeSuccess: '插件 {name} 的仓库已更换',
sourceChangeFailed: '插件 {name} 更换仓库失败:{message}',
systemVersion: '系统版本', systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装', incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
installToLocal: '安装到本地', installToLocal: '安装到本地',
+39 -25
View File
@@ -3777,6 +3777,9 @@ export default {
installed: '已安裝', installed: '已安裝',
notInstalled: '未安裝', notInstalled: '未安裝',
hasUpdate: '可更新', hasUpdate: '可更新',
boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
viewUpdateSources: '查看更新來源',
configuring: '配置中', configuring: '配置中',
enable: '啟用', enable: '啟用',
disable: '禁用', disable: '禁用',
@@ -3792,32 +3795,43 @@ export default {
confirmInstallOldRelease: confirmInstallOldRelease:
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。', '是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地', local: '本地',
source: '插件來源', source: '來源',
sourceOfficial: '官方', sourceOfficial: '官方',
sourceUnknown: '未知來源', sourceUnknown: '未知',
sourceUnbound: '未綁定', sourceUnbound: '未綁定',
sourceLoadFailed: '無法讀取插件來源,請稍後重試', sourceLoadFailed: '來源資訊暫時無法讀取,請稍後重試',
sourceUnavailable: '目前沒有可安全安裝的插件來源', sourceUnavailable: '目前沒有可用倉庫',
sourceInstalledHint: '自動更新只會使用已綁定的可信倉庫。', sourceInstalledHint: '插件只會從已綁定倉庫更新。',
sourceConflictHint: '偵測到多個同名插件,請明確選擇要信任的倉庫', sourceBindingRequired: '需確認倉庫',
sourceBindingHint: '目前插件尚未綁定自動更新來源,請選擇可信倉庫。', sourceBindingRequiredHint: '插件尚未綁定倉庫,請在「關於」中確認',
trustedUpdateSource: '自動更新來源', sourceConflictHint: '偵測到多個同名插件,請選擇倉庫。',
currentPayloadSource: '目前載荷', sourceThirdPartyHint: '該插件來自第三方倉庫,請確認後安裝。',
selectSourceRequired: '選擇插件來源後再安裝', sourceSelectionRequired: '選擇',
bindSource: '綁定來源', sourceConfirmationRequired: '需確認',
confirmSourceBindTitle: '確認綁定插件來源', sourceBindingHint: '目前插件尚未綁定,請選擇倉庫。',
confirmSourceBind: '將插件 {name} 的自動更新來源綁定為 {target}。確認後會立即安裝該來源的目前版本。', trustedUpdateSource: '倉庫',
bindingSource: '正在綁定 {name} 的插件來源...', currentPayloadSource: '運行來源',
sourceBindSuccess: '插件 {name} 的來源已綁定', selectSourceRequired: '請選擇倉庫後再安裝。',
sourceBindFailed: '插件 {name} 綁定來源失敗:{message}', bindSource: '綁定倉庫',
changeSource: '更換來源', bindSourceInline: '綁定',
confirmSourceChangeTitle: '確認更換插件來源', confirmThirdPartyInstallTitle: '確認安裝',
confirmSourceChange: confirmThirdPartyInstall: '將從 {target} 安裝「{name}」,後續更新也將使用該倉庫。',
'將插件 {name} 的自動更新來源從 {current} 更換為 {target}。確認後會立即安裝目標來源的目前版本。', confirmThirdPartyInstallAction: '安裝',
confirmSourceChangeAction: '確認換源', confirmSourceBindTitle: '確認綁定倉庫',
changingSource: '正在更換 {name} 的插件來源...', confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
sourceChangeSuccess: '插件 {name} 的來源已更換', thirdPartySourceRisk: '這是第三方倉庫,後續更新將來自該倉庫,請確認後繼續。',
sourceChangeFailed: '插件 {name} 換源失敗:{message}', confirmSourceBindAction: '確認綁定',
bindingSource: '正在從所選倉庫安裝 {name}...',
sourceBindSuccess: '插件 {name} 已綁定倉庫',
sourceBindFailed: '插件 {name} 綁定倉庫失敗:{message}',
changeSource: '更換倉庫',
changeSourceInline: '更換',
confirmSourceChangeTitle: '確認更換倉庫',
confirmSourceChange: '將插件「{name}」的倉庫從 {current} 更換為 {target},確認後會立即安裝目標倉庫的目前版本。',
confirmSourceChangeAction: '確認更換',
changingSource: '正在更換 {name} 的倉庫...',
sourceChangeSuccess: '插件 {name} 的倉庫已更換',
sourceChangeFailed: '插件 {name} 更換倉庫失敗:{message}',
installToLocal: '安裝到本地', installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載', totalDownloads: '共 {count} 次下載',
rating: '插件評分', rating: '插件評分',
+1
View File
@@ -1097,6 +1097,7 @@ function mergeMarketMetadataIntoInstalled() {
dataList.value.forEach(plugin => { dataList.value.forEach(plugin => {
const marketPlugin = marketById.get(plugin.id) const marketPlugin = marketById.get(plugin.id)
plugin.has_update = Boolean(marketPlugin) plugin.has_update = Boolean(marketPlugin)
plugin.update_candidate = marketPlugin?.update_candidate ?? null
if (!marketPlugin) return if (!marketPlugin) return
plugin.repo_url = marketPlugin.repo_url plugin.repo_url = marketPlugin.repo_url
@@ -172,6 +172,7 @@ const PluginMixedSortCardStub = defineComponent({
plugin_name?: string plugin_name?: string
repo_url?: string repo_url?: string
runtime_status?: Plugin['runtime_status'] runtime_status?: Plugin['runtime_status']
update_candidate?: Plugin['update_candidate']
} }
| undefined | undefined
const name = type === 'folder' ? id : data?.plugin_name || id const name = type === 'folder' ? id : data?.plugin_name || id
@@ -185,6 +186,9 @@ const PluginMixedSortCardStub = defineComponent({
? h('output', { 'aria-label': `update-${id}` }, String(data?.has_update ?? false)) ? h('output', { 'aria-label': `update-${id}` }, String(data?.has_update ?? false))
: h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''), : h('output', { 'aria-label': `folder-color-${id}` }, data?.config?.color || ''),
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': `update-source-${id}` }, data?.update_candidate?.source_key || '')
: 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' ? h('output', { 'aria-label': `installing-${id}` }, String(props.installing)) : null,
@@ -760,15 +764,31 @@ describe('PluginCardListView loading and request ownership', () => {
market: () => { market: () => {
marketRequest += 1 marketRequest += 1
return marketRequest === 1 return marketRequest === 1
? [createPlugin({ has_update: true, id: 'Shared', installed: true, plugin_name: '待更新插件' })] ? [
createPlugin({
has_update: true,
id: 'Shared',
installed: true,
plugin_name: '待更新插件',
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,
},
}),
]
: [] : []
}, },
}) })
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true')) await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('true'))
expect(screen.getByLabelText('update-source-Shared')).toHaveTextContent('github:jxxghp/moviepilot-plugins')
await fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' })) await fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' }))
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false')) await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false'))
expect(screen.getByLabelText('update-source-Shared')).toBeEmptyDOMElement()
expect(marketRequest).toBe(2) expect(marketRequest).toBe(2)
await waitForRequestsToFinish() await waitForRequestsToFinish()
}) })
@@ -1458,7 +1478,7 @@ describe('PluginCardListView search installation', () => {
plugin_id: pluginId, plugin_id: pluginId,
inventory_complete: true, inventory_complete: true,
selection_status: 'conflict', selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择', selection_reason: '插件存在多个在线来源,请确认来源后安装。',
identity: null, identity: null,
candidates: [ candidates: [
{ {
+1 -1
View File
@@ -264,7 +264,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
server: { server: {
proxy: { proxy: {
'/api/v1': { '/api/v1': {
target: 'http://localhost:3001', target: process.env.MOVIEPILOT_DEV_PROXY_TARGET || 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
cookieDomainRewrite: 'localhost', cookieDomainRewrite: 'localhost',