mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-30 12:39:26 +08:00
fix(plugin): 明确插件仓库绑定与换仓确认 (#724)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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: '插件評分',
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user