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 {
modelValue: boolean
type?: 'info' | 'warn' | 'error'
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
icon?: string
title?: string
content?: string
confirmText?: string
@@ -64,11 +66,11 @@ function handleCancel() {
<VCardItem>
<div class="d-flex align-center justify-start mt-3">
<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>
<div class="mx-3">
<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>
</VCardItem>
@@ -84,3 +86,9 @@ function handleCancel() {
</VCard>
</VDialog>
</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_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'
+66 -10
View File
@@ -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(
</div>
</VCardText>
<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"
:aria-label="t('plugin.hasUpdate')"
:title="t('plugin.hasUpdate')"
>
<VIcon icon="mdi-new-box" class="text-white" size="20" />
<VTooltip activator="parent" location="top">{{ updateBadgeTitle }}</VTooltip>
</div>
<div
v-else-if="hasCardRating"
@@ -830,6 +874,18 @@ watch(
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 {
cursor: progress;
}
@@ -199,6 +199,79 @@ describe('PluginCard lifecycle actions', () => {
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 () => {
const updatablePlugin = {
...plugin,
@@ -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(() => {
</div>
</dl>
<VAlert
<p
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="plugin-market-detail__warning"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
class="plugin-market-detail__compatibility"
role="alert"
>
<VIcon icon="mdi-lock-outline" size="16" />
<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">
<div class="plugin-market-detail-source__heading">
<div>
<h3 id="plugin-source-title" class="plugin-market-detail-source__title">
{{ t('plugin.source') }}
<VChip v-if="sourceNeedsSelection" size="x-small" color="warning" variant="tonal">
{{ t(sourceHasConflict ? 'plugin.sourceSelectionRequired' : 'plugin.sourceConfirmationRequired') }}
</VChip>
</h3>
<p class="plugin-market-detail-source__hint">
{{
@@ -523,20 +568,27 @@ onUnmounted(() => {
? t('plugin.sourceBindingHint')
: isInstalled
? t('plugin.sourceInstalledHint')
: t('plugin.sourceConflictHint')
: sourceHasConflict
? t('plugin.sourceConflictHint')
: t('plugin.sourceThirdPartyHint')
}}
</p>
</div>
<VProgressCircular v-if="sourceLoading" indeterminate size="20" width="2" />
</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">
<dl v-if="isInstalled && sourceOptions.identity" class="plugin-market-detail-source__identity">
<div>
<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">
<VChip
v-if="sourceOptions.identity.trusted_source_type === 'official'"
@@ -549,6 +601,20 @@ onUnmounted(() => {
</VChip>
{{ trustedSourceLabel() }}
</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>
</div>
<div v-if="sourceOptions.identity.payload_source_type === 'local'">
@@ -557,14 +623,9 @@ onUnmounted(() => {
</div>
</dl>
<VAlert
v-if="sourceNeedsSelection || sourceNeedsInitialBinding || sourceUnavailable"
:type="sourceNeedsSelection || sourceNeedsInitialBinding ? 'warning' : 'error'"
variant="tonal"
density="compact"
class="mb-3"
:text="sourceOptions.selection_reason"
/>
<p v-if="sourceUnavailable" class="plugin-market-detail-source__message--error" role="alert">
{{ sourceOptions.selection_reason }}
</p>
<VRadioGroup
v-if="sourceNeedsSelection"
@@ -593,67 +654,61 @@ onUnmounted(() => {
<strong>{{ sourceCandidateLabel(candidate) }}</strong>
</span>
<span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation.toUpperCase() }}</span
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}</span
>
</span>
</template>
</VRadio>
</VRadioGroup>
<div v-if="isInstalled && sourceActionCandidates.length > 0" class="plugin-market-detail-source__change">
<VBtn
v-if="!showSourceChoices"
size="small"
variant="text"
prepend-icon="mdi-source-branch"
@click="showSourceChoices = true"
>
{{ t(hasTrustedOnlineSource ? 'plugin.changeSource' : 'plugin.bindSource') }}
</VBtn>
<template v-else>
<VRadioGroup v-model="selectedChangeSourceKey" hide-details>
<VRadio
v-for="candidate in sourceActionCandidates"
:key="candidate.source_key"
:value="candidate.source_key"
>
<template #label>
<span class="plugin-market-detail-source__choice-label">
<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
<div
v-if="isInstalled && sourceActionCandidates.length > 0 && showSourceChoices"
class="plugin-market-detail-source__change"
>
<VRadioGroup v-model="selectedChangeSourceKey" hide-details>
<VRadio
v-for="candidate in sourceActionCandidates"
:key="candidate.source_key"
:value="candidate.source_key"
>
<template #label>
<span class="plugin-market-detail-source__choice-label">
<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>
</template>
</VRadio>
</VRadioGroup>
<div class="plugin-market-detail-source__change-actions">
<VBtn size="small" variant="text" @click="showSourceChoices = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn
size="small"
color="warning"
:loading="sourceChanging"
:disabled="!selectedChangeSource"
@click="confirmSourceTransition"
>
{{ t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.bindSource') }}
</VBtn>
</div>
</template>
<span class="plugin-market-detail-source__choice-meta"
>v{{ candidate.plugin_version || '-' }} · {{ candidate.package_generation }}</span
>
</span>
</template>
</VRadio>
</VRadioGroup>
<div class="plugin-market-detail-source__change-actions">
<VBtn size="small" variant="text" @click="showSourceChoices = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn
size="small"
color="primary"
variant="text"
:loading="sourceChanging"
:disabled="!selectedChangeSource"
@click="confirmSourceTransition"
>
{{
t(hasTrustedOnlineSource ? 'plugin.confirmSourceChangeAction' : 'plugin.confirmSourceBindAction')
}}
</VBtn>
</div>
</div>
</template>
</section>
@@ -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 {
@@ -359,14 +359,14 @@ watch(
<template v-if="shouldShowUpdatePanel">
<VDivider />
<VCardItem>
<VAlert
<p
v-if="resolvedPlugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
class="plugin-version-history-dialog__compatibility"
role="alert"
>
<VIcon icon="mdi-lock-outline" size="16" />
<span>{{ resolvedPlugin?.system_version_message || t('plugin.incompatibleSystemVersion') }}</span>
</p>
<VBtn
@click="handleUpdate()"
block
@@ -395,6 +395,23 @@ watch(
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 {
display: flex;
align-items: center;
@@ -127,6 +127,10 @@ describe('PluginMarketDetailDialog', () => {
await renderDialog({ ...basePlugin, installed: false })
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.getByLabelText('4.3 / 5')).toBeInTheDocument()
})
@@ -139,7 +143,7 @@ describe('PluginMarketDetailDialog', () => {
...defaultSourceOptions,
identity: null,
selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
selection_reason: '插件存在多个在线来源,请确认来源后安装。',
candidates: [
defaultSourceOptions.candidates[0],
{
@@ -156,7 +160,9 @@ describe('PluginMarketDetailDialog', () => {
})
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: '安装到本地' })
expect(installButton).toBeEnabled()
@@ -207,20 +213,21 @@ describe('PluginMarketDetailDialog', () => {
})
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('官方')).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.getByRole('button', { name: '确认换' }))
await fireEvent.click(screen.getByRole('button', { name: '确认换' }))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
icon: 'mdi-source-branch',
content: expect.stringContaining('jxxghp/moviepilot-plugins'),
confirmText: '更换来源',
confirmText: '确认更换',
}),
)
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin', {
@@ -237,7 +244,7 @@ describe('PluginMarketDetailDialog', () => {
return Promise.resolve({
...defaultSourceOptions,
selection_status: 'unavailable',
selection_reason: '当前来源身份没有可用候选',
selection_reason: '已绑定仓库中暂无可用插件包',
identity: {
plugin_id: 'DemoPlugin',
trusted_source_type: 'official',
@@ -254,8 +261,8 @@ describe('PluginMarketDetailDialog', () => {
})
await renderDialog({ ...basePlugin, installed: true, has_update: true })
expect(await screen.findByText('当前来源身份没有可用候选')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更换来源' })).toBeInTheDocument()
expect(await screen.findByText('已绑定仓库中暂无可用插件包')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更换' })).toBeInTheDocument()
const updateButton = screen.getByRole('button', { name: '更新' })
expect(updateButton).toBeDisabled()
await fireEvent.click(updateButton)
@@ -295,16 +302,17 @@ describe('PluginMarketDetailDialog', () => {
})
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()
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.getByRole('button', { name: '绑定来源' }))
await fireEvent.click(screen.getByRole('button', { name: '确认绑定' }))
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: '确认绑定插件来源',
confirmText: '绑定来源',
icon: 'mdi-shield-check-outline',
title: '确认绑定仓库',
confirmText: '确认绑定',
}),
)
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(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 () => {
@@ -390,9 +398,9 @@ describe('PluginMarketDetailDialog', () => {
})
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.getByRole('button', { name: '确认换' }))
await fireEvent.click(screen.getByRole('button', { name: '确认换' }))
await waitFor(() => expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('revision 已变化')))
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
@@ -485,13 +493,19 @@ describe('PluginMarketDetailDialog', () => {
await fireEvent.click(await screen.findByRole('button', { name: '安装到本地' }))
await waitFor(() => {
expect(mocks.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
params: {
force: false,
release_version: undefined,
},
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
repo_url: 'https://github.com/example/plugins',
release_version: undefined,
force: false,
})
})
expect(mocks.confirm).toHaveBeenCalledWith(
expect.objectContaining({
title: '确认安装',
confirmText: '安装',
content: expect.stringContaining('后续更新也将使用该仓库'),
}),
)
expect(mocks.toastSuccess).toHaveBeenCalledWith('插件 演示插件 安装成功!')
expect(emitted().install).toHaveLength(1)
expect(emitted()['update:modelValue']).toContainEqual([false])
@@ -504,7 +518,11 @@ describe('PluginMarketDetailDialog', () => {
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(emitted().install).toBeUndefined()
expect(emitted()['update:modelValue']).toContainEqual([false])
@@ -565,11 +583,10 @@ describe('PluginMarketDetailDialog', () => {
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.apiGet).toHaveBeenCalledWith('plugin/install/DemoPlugin', {
params: {
force: true,
release_version: '0.9.0',
},
expect(mocks.apiPost).toHaveBeenCalledWith('plugin/source/DemoPlugin/install', {
repo_url: 'https://github.com/example/plugins',
release_version: '0.9.0',
force: true,
})
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 () => {
const legacySourceOptions: PluginSourceOptions = {
...sourceOptions,
@@ -163,10 +189,10 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update',
})
expect(await screen.findByRole('button', { name: '绑定来源' })).toBeInTheDocument()
expect(await screen.findByText('当前插件尚未绑定自动更新来源,请选择可信仓库。')).toBeInTheDocument()
expect(await screen.findByRole('button', { name: '绑定仓库' })).toBeInTheDocument()
expect(await screen.findByText('当前插件尚未绑定,请选择仓库。')).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().update).toBeUndefined()
@@ -249,7 +275,7 @@ describe('PluginVersionHistoryDialog', () => {
actionMode: 'update',
})
const bindButton = await screen.findByRole('button', { name: '绑定来源' })
const bindButton = await screen.findByRole('button', { name: '绑定仓库' })
await fireEvent.click(bindButton)
expect(emitted().sourceAction).toEqual([[]])
@@ -299,7 +325,7 @@ describe('PluginVersionHistoryDialog', () => {
const unavailableSourceOptions: PluginSourceOptions = {
...sourceOptions,
selection_status: 'unavailable',
selection_reason: '当前来源身份没有可用候选',
selection_reason: '已绑定仓库中暂无可用插件包',
candidates: [],
}
mocks.apiGet.mockImplementation((url: string) => {
@@ -317,7 +343,7 @@ describe('PluginVersionHistoryDialog', () => {
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(emitted().update).toBeUndefined()
+2
View File
@@ -7,6 +7,8 @@ import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
/** 主应用确认弹窗支持的配置项。 */
export interface ConfirmOptions {
type?: 'info' | 'warn' | 'error'
/** 覆盖确认类型的默认图标,用于表达更具体的操作语义。 */
icon?: string
title?: string
content?: string
confirmText?: string
+38 -23
View File
@@ -3841,6 +3841,9 @@ export default {
installed: 'Installed',
notInstalled: 'Not Installed',
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',
enable: 'Enable',
disable: 'Disable',
@@ -3856,33 +3859,45 @@ export default {
confirmInstallOldRelease:
'Install {name} v{version}? This version has no MoviePilot compatibility metadata and may fail to load or run.',
local: 'Local',
source: 'Plugin Source',
source: 'Source',
sourceOfficial: 'Official',
sourceUnknown: 'Unknown source',
sourceUnknown: 'Unknown',
sourceUnbound: 'Not bound',
sourceLoadFailed: 'Unable to load plugin sources. Try again later.',
sourceUnavailable: 'No plugin source is currently safe to install',
sourceInstalledHint: 'Automatic updates only use the bound trusted repository.',
sourceConflictHint: 'Multiple plugins share this ID. Choose the repository you trust.',
sourceBindingHint: 'This plugin has no automatic update source yet. Choose a repository you trust.',
trustedUpdateSource: 'Automatic update source',
currentPayloadSource: 'Current payload',
selectSourceRequired: 'Choose a plugin source before installing',
bindSource: 'Bind Source',
confirmSourceBindTitle: 'Confirm Plugin Source',
confirmSourceBind:
'Bind the automatic update source for {name} to {target}. The current version from that source will be installed immediately.',
bindingSource: 'Binding the plugin source for {name}...',
sourceBindSuccess: 'The source for plugin {name} was bound',
sourceBindFailed: 'Failed to bind the source for plugin {name}: {message}',
changeSource: 'Change Source',
confirmSourceChangeTitle: 'Confirm Plugin Source Change',
sourceLoadFailed: 'Source information is temporarily unavailable. Try again later.',
sourceUnavailable: 'No repository is currently available',
sourceInstalledHint: 'Plugins are updated only from the bound repository.',
sourceBindingRequired: 'Repository confirmation required',
sourceBindingRequiredHint: 'This plugin has no bound repository; open About to confirm',
sourceConflictHint: 'Multiple plugins share this ID. Choose a repository.',
sourceThirdPartyHint: 'This plugin comes from a third-party repository. Confirm before installing.',
sourceSelectionRequired: 'Selection required',
sourceConfirmationRequired: 'Confirmation required',
sourceBindingHint: 'This plugin is not bound. Choose a repository.',
trustedUpdateSource: 'Repository',
currentPayloadSource: 'Runtime source',
selectSourceRequired: 'Choose a repository before installing.',
bindSource: 'Bind Repository',
bindSourceInline: 'Bind',
confirmThirdPartyInstallTitle: 'Confirm Installation',
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:
'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',
changingSource: 'Changing the plugin source for {name}...',
sourceChangeSuccess: 'The source for plugin {name} was changed',
sourceChangeFailed: 'Failed to change the source for plugin {name}: {message}',
changingSource: 'Changing the repository for {name}...',
sourceChangeSuccess: 'The repository for {name} was changed',
sourceChangeFailed: 'Failed to change the repository for {name}: {message}',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
installToLocal: 'Install to Local',
+39 -25
View File
@@ -3778,6 +3778,9 @@ export default {
installed: '已安装',
notInstalled: '未安装',
hasUpdate: '有更新',
boundUpdateAvailable: '{source} 有可直接安装的新版本 v{version}',
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要确认更换仓库',
viewUpdateSources: '查看更新来源',
configuring: '配置',
enable: '启用',
disable: '禁用',
@@ -3793,32 +3796,43 @@ export default {
confirmInstallOldRelease:
'是否确认安装 {name} v{version}?该版本缺少主程序兼容元数据,安装后可能无法加载或运行异常。',
local: '本地',
source: '插件来源',
source: '来源',
sourceOfficial: '官方',
sourceUnknown: '未知来源',
sourceUnbound: '未绑定',
sourceLoadFailed: '无法读取插件来源,请稍后重试',
sourceUnavailable: '当前没有可安全安装的插件来源',
sourceInstalledHint: '自动更新只会使用已绑定的可信仓库。',
sourceConflictHint: '检测到多个同名插件,请明确选择要信任的仓库',
sourceBindingHint: '当前插件尚未绑定自动更新来源,请选择可信仓库。',
trustedUpdateSource: '自动更新来源',
currentPayloadSource: '当前载荷',
selectSourceRequired: '选择插件来源后再安装',
bindSource: '绑定来源',
confirmSourceBindTitle: '确认绑定插件来源',
confirmSourceBind: '将插件 {name} 的自动更新来源绑定为 {target}。确认后会立即安装该来源的当前版本。',
bindingSource: '正在绑定 {name} 的插件来源...',
sourceBindSuccess: '插件 {name} 的来源已绑定',
sourceBindFailed: '插件 {name} 绑定来源失败:{message}',
changeSource: '更换来源',
confirmSourceChangeTitle: '确认更换插件来源',
confirmSourceChange:
'将插件 {name} 的自动更新来源从 {current} 更换为 {target}。确认后会立即安装目标来源的当前版本。',
confirmSourceChangeAction: '确认换源',
changingSource: '正在更换 {name} 的插件来源...',
sourceChangeSuccess: '插件 {name} 的来源已更换',
sourceChangeFailed: '插件 {name} 换源失败:{message}',
sourceUnknown: '未知',
sourceUnbound: '未绑定',
sourceLoadFailed: '来源信息暂时无法读取,请稍后重试',
sourceUnavailable: '当前没有可用仓库',
sourceInstalledHint: '插件只会从已绑定仓库更新。',
sourceBindingRequired: '需确认仓库',
sourceBindingRequiredHint: '插件尚未绑定仓库,请在「关于」中确认',
sourceConflictHint: '检测到多个同名插件,请选择仓库。',
sourceThirdPartyHint: '该插件来自第三方仓库,请确认后安装。',
sourceSelectionRequired: '选择',
sourceConfirmationRequired: '需确认',
sourceBindingHint: '当前插件尚未绑定,请选择仓库。',
trustedUpdateSource: '仓库',
currentPayloadSource: '运行来源',
selectSourceRequired: '请选择仓库后再安装。',
bindSource: '绑定仓库',
bindSourceInline: '绑定',
confirmThirdPartyInstallTitle: '确认安装',
confirmThirdPartyInstall: '将从 {target} 安装「{name}」,后续更新也将使用该仓库。',
confirmThirdPartyInstallAction: '安装',
confirmSourceBindTitle: '确认绑定仓库',
confirmSourceBind: '将插件「{name}」绑定到 {target},并安装该仓库的当前版本。',
thirdPartySourceRisk: '这是第三方仓库,后续更新将来自该仓库,请确认后继续。',
confirmSourceBindAction: '确认绑定',
bindingSource: '正在从所选仓库安装 {name}...',
sourceBindSuccess: '插件 {name} 已绑定仓库',
sourceBindFailed: '插件 {name} 绑定仓库失败:{message}',
changeSource: '更换仓库',
changeSourceInline: '更换',
confirmSourceChangeTitle: '确认更换仓库',
confirmSourceChange: '将插件「{name}」的仓库从 {current} 更换为 {target},确认后会立即安装目标仓库的当前版本。',
confirmSourceChangeAction: '确认更换',
changingSource: '正在更换 {name} 的仓库...',
sourceChangeSuccess: '插件 {name} 的仓库已更换',
sourceChangeFailed: '插件 {name} 更换仓库失败:{message}',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
installToLocal: '安装到本地',
+39 -25
View File
@@ -3777,6 +3777,9 @@ export default {
installed: '已安裝',
notInstalled: '未安裝',
hasUpdate: '可更新',
boundUpdateAvailable: '{source} 有可直接安裝的新版本 v{version}',
alternativeUpdateAvailable: '{source} 有新版本 v{version},需要確認更換倉庫',
viewUpdateSources: '查看更新來源',
configuring: '配置中',
enable: '啟用',
disable: '禁用',
@@ -3792,32 +3795,43 @@ export default {
confirmInstallOldRelease:
'是否確認安裝 {name} v{version}?該版本缺少主程序兼容元數據,安裝後可能無法載入或運行異常。',
local: '本地',
source: '插件來源',
source: '來源',
sourceOfficial: '官方',
sourceUnknown: '未知來源',
sourceUnbound: '未綁定',
sourceLoadFailed: '無法讀取插件來源,請稍後重試',
sourceUnavailable: '目前沒有可安全安裝的插件來源',
sourceInstalledHint: '自動更新只會使用已綁定的可信倉庫。',
sourceConflictHint: '偵測到多個同名插件,請明確選擇要信任的倉庫',
sourceBindingHint: '目前插件尚未綁定自動更新來源,請選擇可信倉庫。',
trustedUpdateSource: '自動更新來源',
currentPayloadSource: '目前載荷',
selectSourceRequired: '選擇插件來源後再安裝',
bindSource: '綁定來源',
confirmSourceBindTitle: '確認綁定插件來源',
confirmSourceBind: '將插件 {name} 的自動更新來源綁定為 {target}。確認後會立即安裝該來源的目前版本。',
bindingSource: '正在綁定 {name} 的插件來源...',
sourceBindSuccess: '插件 {name} 的來源已綁定',
sourceBindFailed: '插件 {name} 綁定來源失敗:{message}',
changeSource: '更換來源',
confirmSourceChangeTitle: '確認更換插件來源',
confirmSourceChange:
'將插件 {name} 的自動更新來源從 {current} 更換為 {target}。確認後會立即安裝目標來源的目前版本。',
confirmSourceChangeAction: '確認換源',
changingSource: '正在更換 {name} 的插件來源...',
sourceChangeSuccess: '插件 {name} 的來源已更換',
sourceChangeFailed: '插件 {name} 換源失敗:{message}',
sourceUnknown: '未知',
sourceUnbound: '未綁定',
sourceLoadFailed: '來源資訊暫時無法讀取,請稍後重試',
sourceUnavailable: '目前沒有可用倉庫',
sourceInstalledHint: '插件只會從已綁定倉庫更新。',
sourceBindingRequired: '需確認倉庫',
sourceBindingRequiredHint: '插件尚未綁定倉庫,請在「關於」中確認',
sourceConflictHint: '偵測到多個同名插件,請選擇倉庫。',
sourceThirdPartyHint: '該插件來自第三方倉庫,請確認後安裝。',
sourceSelectionRequired: '選擇',
sourceConfirmationRequired: '需確認',
sourceBindingHint: '目前插件尚未綁定,請選擇倉庫。',
trustedUpdateSource: '倉庫',
currentPayloadSource: '運行來源',
selectSourceRequired: '請選擇倉庫後再安裝。',
bindSource: '綁定倉庫',
bindSourceInline: '綁定',
confirmThirdPartyInstallTitle: '確認安裝',
confirmThirdPartyInstall: '將從 {target} 安裝「{name}」,後續更新也將使用該倉庫。',
confirmThirdPartyInstallAction: '安裝',
confirmSourceBindTitle: '確認綁定倉庫',
confirmSourceBind: '將插件「{name}」綁定到 {target},並安裝該倉庫的目前版本。',
thirdPartySourceRisk: '這是第三方倉庫,後續更新將來自該倉庫,請確認後繼續。',
confirmSourceBindAction: '確認綁定',
bindingSource: '正在從所選倉庫安裝 {name}...',
sourceBindSuccess: '插件 {name} 已綁定倉庫',
sourceBindFailed: '插件 {name} 綁定倉庫失敗:{message}',
changeSource: '更換倉庫',
changeSourceInline: '更換',
confirmSourceChangeTitle: '確認更換倉庫',
confirmSourceChange: '將插件「{name}」的倉庫從 {current} 更換為 {target},確認後會立即安裝目標倉庫的目前版本。',
confirmSourceChangeAction: '確認更換',
changingSource: '正在更換 {name} 的倉庫...',
sourceChangeSuccess: '插件 {name} 的倉庫已更換',
sourceChangeFailed: '插件 {name} 更換倉庫失敗:{message}',
installToLocal: '安裝到本地',
totalDownloads: '共 {count} 次下載',
rating: '插件評分',
+1
View File
@@ -1097,6 +1097,7 @@ function mergeMarketMetadataIntoInstalled() {
dataList.value.forEach(plugin => {
const marketPlugin = marketById.get(plugin.id)
plugin.has_update = Boolean(marketPlugin)
plugin.update_candidate = marketPlugin?.update_candidate ?? null
if (!marketPlugin) return
plugin.repo_url = marketPlugin.repo_url
@@ -172,6 +172,7 @@ const PluginMixedSortCardStub = defineComponent({
plugin_name?: string
repo_url?: string
runtime_status?: Plugin['runtime_status']
update_candidate?: Plugin['update_candidate']
}
| undefined
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': `folder-color-${id}` }, data?.config?.color || ''),
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': `settling-${id}` }, String(props.runtimeSettling)) : null,
type === 'plugin' ? h('output', { 'aria-label': `installing-${id}` }, String(props.installing)) : null,
@@ -760,15 +764,31 @@ describe('PluginCardListView loading and request ownership', () => {
market: () => {
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'))
expect(screen.getByLabelText('update-source-Shared')).toHaveTextContent('github:jxxghp/moviepilot-plugins')
await fireEvent.click(screen.getByRole('button', { name: 'refresh-plugin-Shared' }))
await waitFor(() => expect(screen.getByLabelText('update-Shared')).toHaveTextContent('false'))
expect(screen.getByLabelText('update-source-Shared')).toBeEmptyDOMElement()
expect(marketRequest).toBe(2)
await waitForRequestsToFinish()
})
@@ -1458,7 +1478,7 @@ describe('PluginCardListView search installation', () => {
plugin_id: pluginId,
inventory_complete: true,
selection_status: 'conflict',
selection_reason: '未安装插件存在多个在线来源,不能静默选择',
selection_reason: '插件存在多个在线来源,请确认来源后安装。',
identity: null,
candidates: [
{
+1 -1
View File
@@ -264,7 +264,7 @@ export default defineConfig(({ command, mode, isPreview }) => ({
server: {
proxy: {
'/api/v1': {
target: 'http://localhost:3001',
target: process.env.MOVIEPILOT_DEV_PROXY_TARGET || 'http://localhost:3001',
changeOrigin: true,
secure: false,
cookieDomainRewrite: 'localhost',